Compare commits
29 Commits
438a5b5b05
...
8ec8d942ea
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ec8d942ea | |||
| bf6724952a | |||
| 6edcebe51f | |||
| 035ad464f7 | |||
| 698c945cef | |||
| 84adcce57b | |||
| fe2ac75aa8 | |||
| 09bef4e1b9 | |||
| c3c8f74c91 | |||
| 9e07b768f5 | |||
| 9a06d460e5 | |||
| 9dbf90aca9 | |||
| e882601b85 | |||
| 57da952b80 | |||
| babaee0ca3 | |||
| 79eb66cf51 | |||
| 65b550f8c8 | |||
| 71b91bf9bb | |||
| 29a6ac2bd2 | |||
| 1d715d4f63 | |||
| 45cb526854 | |||
| 7b3f459b80 | |||
| 27db838b32 | |||
| 7343c1af4c | |||
| 712577e694 | |||
| 2d03c3e14c | |||
| ce696b1e76 | |||
| 43d46ea6f8 | |||
| 0fdf01d1c7 |
19
.env.prod
Normal file
19
.env.prod
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Пример конфигурации для продакшн-окружения
|
||||||
|
# Скопируйте этот файл в .env.prod и заполните реальными значениями
|
||||||
|
|
||||||
|
# Telegram Bot Token
|
||||||
|
BOT_TOKEN=8300330445:AAFyxAqtmWsgtSPa_nb-lH3Q4ovmn9Ei6rA
|
||||||
|
|
||||||
|
# PostgreSQL настройки
|
||||||
|
POSTGRES_DB=bot_db
|
||||||
|
POSTGRES_USER=trevor
|
||||||
|
POSTGRES_PASSWORD=Cl0ud_1985!
|
||||||
|
|
||||||
|
# Database URL для бота (используется внутри контейнера)
|
||||||
|
DATABASE_URL=postgresql+asyncpg://trevor:Cl0ud_1985!@localhost:5432/bot_db
|
||||||
|
|
||||||
|
# ID администраторов (через запятую)
|
||||||
|
ADMIN_IDS=556399210,6639865742
|
||||||
|
|
||||||
|
# Настройки логирования
|
||||||
|
LOG_LEVEL=INFO
|
||||||
19
.env.prod.example
Normal file
19
.env.prod.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Пример конфигурации для продакшн-окружения
|
||||||
|
# Скопируйте этот файл в .env.prod и заполните реальными значениями
|
||||||
|
|
||||||
|
# Telegram Bot Token
|
||||||
|
BOT_TOKEN=your_bot_token_here
|
||||||
|
|
||||||
|
# PostgreSQL настройки
|
||||||
|
POSTGRES_DB=lottery_bot_db
|
||||||
|
POSTGRES_USER=lottery_user
|
||||||
|
POSTGRES_PASSWORD=your_strong_password_here
|
||||||
|
|
||||||
|
# Database URL для бота (используется внутри контейнера)
|
||||||
|
DATABASE_URL=postgresql+asyncpg://lottery_user:your_strong_password_here@db:5432/lottery_bot_db
|
||||||
|
|
||||||
|
# ID администраторов (через запятую)
|
||||||
|
ADMIN_IDS=123456789,987654321
|
||||||
|
|
||||||
|
# Настройки логирования
|
||||||
|
LOG_LEVEL=INFO
|
||||||
148
ADMIN_PANEL_STRUCTURE.md
Normal file
148
ADMIN_PANEL_STRUCTURE.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# Структура Админ Панели
|
||||||
|
|
||||||
|
## Главное меню бота
|
||||||
|
|
||||||
|
### Для всех пользователей:
|
||||||
|
- **🎲 Активные розыгрыши** (`active_lotteries`) - Просмотр всех активных розыгрышей
|
||||||
|
- **📝 Зарегистрироваться** (`start_registration`) - Регистрация в системе (скрывается для зарегистрированных и админов)
|
||||||
|
|
||||||
|
### Для администраторов:
|
||||||
|
- **⚙️ Админ панель** (`admin_panel`) - Вход в админ панель
|
||||||
|
- **➕ Создать розыгрыш** (`create_lottery`) - Быстрое создание розыгрыша
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Админ панель (`admin_panel`)
|
||||||
|
|
||||||
|
### Основные разделы:
|
||||||
|
|
||||||
|
#### 1. 🎲 Управление розыгрышами (`admin_lotteries`)
|
||||||
|
Раздел для полного управления розыгрышами.
|
||||||
|
|
||||||
|
**Доступные действия:**
|
||||||
|
- **➕ Создать розыгрыш** (`admin_create_lottery`) - Создание нового розыгрыша (пошаговый процесс)
|
||||||
|
- **📝 Редактировать розыгрыш** (`admin_edit_lottery`) - Редактирование существующих розыгрышей
|
||||||
|
- **🎭 Настройка отображения победителей** (`admin_winner_display_settings`) - Настройка способа отображения победителей (номер счета/имя)
|
||||||
|
- **📋 Список всех розыгрышей** (`admin_list_all_lotteries`) - Просмотр всех розыгрышей (активные и завершенные)
|
||||||
|
- **🏁 Завершить розыгрыш** (`admin_finish_lottery`) - Принудительное завершение розыгрыша
|
||||||
|
- **🗑️ Удалить розыгрыш** (`admin_delete_lottery`) - Удаление розыгрыша из системы
|
||||||
|
|
||||||
|
**Состояния (FSM):**
|
||||||
|
- `lottery_title` - Ввод названия розыгрыша
|
||||||
|
- `lottery_description` - Ввод описания
|
||||||
|
- `lottery_prizes` - Ввод списка призов
|
||||||
|
- `lottery_confirm` - Подтверждение создания
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. 👥 Управление участниками (`admin_participants`)
|
||||||
|
Раздел для управления участниками розыгрышей.
|
||||||
|
|
||||||
|
**Доступные действия:**
|
||||||
|
- **➕ Добавить участника** (`admin_add_participant`) - Добавление одного участника вручную
|
||||||
|
- **📥 Массовое добавление (ID)** (`admin_bulk_add_participant`) - Массовое добавление по Telegram ID
|
||||||
|
- **🏦 Массовое добавление (счета)** (`admin_bulk_add_accounts`) - Массовое добавление по номерам счетов
|
||||||
|
- **➖ Удалить участника** (`admin_remove_participant`) - Удаление одного участника
|
||||||
|
- **📤 Массовое удаление (ID)** (`admin_bulk_remove_participant`) - Массовое удаление по Telegram ID
|
||||||
|
- **🏦 Массовое удаление (счета)** (`admin_bulk_remove_accounts`) - Массовое удаление по номерам счетов
|
||||||
|
- **👥 Все участники** (`admin_list_all_participants`) - Список всех зарегистрированных участников
|
||||||
|
- **🔍 Поиск участников** (`admin_search_participants`) - Поиск участников по критериям
|
||||||
|
- **📊 Участники по розыгрышам** (`admin_participants_by_lottery`) - Просмотр участников конкретного розыгрыша
|
||||||
|
- **📈 Отчет по участникам** (`admin_participants_report`) - Детальный отчет об участии
|
||||||
|
|
||||||
|
**Состояния (FSM):**
|
||||||
|
- `add_participant_lottery` - Выбор розыгрыша для добавления
|
||||||
|
- `add_participant_user` - Выбор пользователя
|
||||||
|
- `add_participant_bulk` - Массовый ввод ID
|
||||||
|
- `add_participant_bulk_accounts` - Массовый ввод счетов
|
||||||
|
- `remove_participant_lottery` - Выбор розыгрыша для удаления
|
||||||
|
- `remove_participant_user` - Выбор пользователя для удаления
|
||||||
|
- `participant_search` - Поиск участников
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. 👑 Управление победителями (`admin_winners`)
|
||||||
|
Раздел для управления победителями розыгрышей.
|
||||||
|
|
||||||
|
**Доступные действия:**
|
||||||
|
- **👑 Установить победителя** (`admin_set_manual_winner`) - Ручная установка победителя (без розыгрыша)
|
||||||
|
- **📝 Изменить победителя** (`admin_edit_winner`) - Изменение данных победителя
|
||||||
|
- **❌ Удалить победителя** (`admin_remove_winner`) - Удаление победителя
|
||||||
|
- **📋 Список победителей** (`admin_list_winners`) - Просмотр всех победителей
|
||||||
|
- **🎲 Провести розыгрыш** (`admin_conduct_draw`) - Автоматическое проведение розыгрыша
|
||||||
|
|
||||||
|
**Состояния (FSM):**
|
||||||
|
- `set_winner_lottery` - Выбор розыгрыша для установки победителя
|
||||||
|
- `set_winner_place` - Выбор места (1, 2, 3...)
|
||||||
|
- `set_winner_user` - Выбор пользователя-победителя
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. 📊 Статистика (`admin_stats`)
|
||||||
|
Раздел с общей статистикой системы.
|
||||||
|
|
||||||
|
**Показывает:**
|
||||||
|
- Количество пользователей
|
||||||
|
- Количество зарегистрированных пользователей
|
||||||
|
- Общее количество розыгрышей
|
||||||
|
- Количество активных розыгрышей
|
||||||
|
- Количество завершенных розыгрышей
|
||||||
|
- Общее количество участий
|
||||||
|
|
||||||
|
**Кнопки:**
|
||||||
|
- **🔄 Обновить** (`admin_stats`) - Обновление статистики
|
||||||
|
- **🔙 Назад** (`admin_panel`) - Возврат в главное меню админ панели
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5. ⚙️ Настройки (`admin_settings`)
|
||||||
|
Раздел с настройками системы и утилитами.
|
||||||
|
|
||||||
|
**Доступные действия:**
|
||||||
|
- Настройки отображения
|
||||||
|
- Управление базой данных
|
||||||
|
- Экспорт данных
|
||||||
|
- Системные настройки
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Навигация
|
||||||
|
|
||||||
|
### Кнопки возврата:
|
||||||
|
- **🔙 Назад** (`admin_panel`) - Возврат в главное меню админ панели
|
||||||
|
- **🔙 Назад** (`back_to_main`) - Возврат в главное меню бота
|
||||||
|
|
||||||
|
### Кнопки отмены:
|
||||||
|
- **❌ Отмена** - Отмена текущей операции и возврат в предыдущее меню
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Обработка callback'ов
|
||||||
|
|
||||||
|
### Главный роутер (`main.py`):
|
||||||
|
- `admin_panel` - Открытие админ панели (через контроллер)
|
||||||
|
- `back_to_main` - Возврат в главное меню
|
||||||
|
|
||||||
|
### Админ роутер (`admin_panel.py`):
|
||||||
|
- Все callback'и начинающиеся с `admin_*`
|
||||||
|
- Вся логика управления розыгрышами, участниками, победителями
|
||||||
|
- FSM состояния для многошаговых операций
|
||||||
|
|
||||||
|
### Порядок подключения роутеров:
|
||||||
|
1. **router** (main) - команды `/start`, `/help`, основные callback'и
|
||||||
|
2. **admin_router** - все админские операции
|
||||||
|
3. **registration_router** - регистрация пользователей
|
||||||
|
4. **chat_router** (ПОСЛЕДНИЙ) - обработка всех необработанных сообщений
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Проверка прав доступа
|
||||||
|
|
||||||
|
Все админские handler'ы проверяют права доступа:
|
||||||
|
```python
|
||||||
|
if not is_admin(callback.from_user.id):
|
||||||
|
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
ID администраторов хранятся в `src/core/config.py` в переменной `ADMIN_IDS`.
|
||||||
331
ADMIN_PANEL_TESTING.md
Normal file
331
ADMIN_PANEL_TESTING.md
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
# Тестирование Админ Панели
|
||||||
|
|
||||||
|
## Контрольный список для проверки кнопок
|
||||||
|
|
||||||
|
### ✅ Главное меню (для обычных пользователей)
|
||||||
|
- [ ] 🎲 Активные розыгрыши - показывает список активных розыгрышей
|
||||||
|
- [ ] 📝 Зарегистрироваться - открывает форму регистрации (только для незарегистрированных)
|
||||||
|
- [ ] Кнопка регистрации СКРЫТА для зарегистрированных пользователей
|
||||||
|
- [ ] Кнопка регистрации СКРЫТА для администраторов
|
||||||
|
|
||||||
|
### ✅ Главное меню (для администраторов)
|
||||||
|
- [ ] 🎲 Активные розыгрыши - показывает список активных розыгрышей
|
||||||
|
- [ ] ⚙️ Админ панель - открывает админ панель
|
||||||
|
- [ ] ➕ Создать розыгрыш - быстрое создание розыгрыша
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Админ панель - Главное меню
|
||||||
|
|
||||||
|
### Проверка открытия админ панели:
|
||||||
|
- [ ] Показывается краткая статистика (пользователи, розыгрыши, участия)
|
||||||
|
- [ ] Все 6 кнопок отображаются корректно
|
||||||
|
|
||||||
|
### Основные кнопки:
|
||||||
|
- [ ] 🎲 Управление розыгрышами (`admin_lotteries`)
|
||||||
|
- [ ] 👥 Управление участниками (`admin_participants`)
|
||||||
|
- [ ] 👑 Управление победителями (`admin_winners`)
|
||||||
|
- [ ] 📊 Статистика (`admin_stats`)
|
||||||
|
- [ ] ⚙️ Настройки (`admin_settings`)
|
||||||
|
- [ ] 🔙 Назад - возврат в главное меню бота
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Раздел: Управление розыгрышами
|
||||||
|
|
||||||
|
### Открытие раздела:
|
||||||
|
- [ ] Нажать "🎲 Управление розыгрышами" в админ панели
|
||||||
|
- [ ] Проверить отображение всех 7 кнопок
|
||||||
|
|
||||||
|
### Кнопки раздела:
|
||||||
|
- [ ] ➕ Создать розыгрыш
|
||||||
|
- [ ] Запускается процесс создания (FSM)
|
||||||
|
- [ ] Шаг 1: Ввод названия
|
||||||
|
- [ ] Шаг 2: Ввод описания
|
||||||
|
- [ ] Шаг 3: Ввод призов (через запятую)
|
||||||
|
- [ ] Шаг 4: Подтверждение
|
||||||
|
- [ ] Розыгрыш создается в БД
|
||||||
|
- [ ] Кнопка "❌ Отмена" работает на каждом шаге
|
||||||
|
|
||||||
|
- [ ] 📝 Редактировать розыгрыш
|
||||||
|
- [ ] Показывает список всех розыгрышей
|
||||||
|
- [ ] Выбор розыгрыша открывает меню редактирования
|
||||||
|
- [ ] Можно изменить название, описание, призы
|
||||||
|
- [ ] Изменения сохраняются в БД
|
||||||
|
|
||||||
|
- [ ] 🎭 Настройка отображения победителей
|
||||||
|
- [ ] Показывает список розыгрышей
|
||||||
|
- [ ] Для каждого розыгрыша можно выбрать тип отображения:
|
||||||
|
- [ ] По номеру счета
|
||||||
|
- [ ] По имени пользователя
|
||||||
|
- [ ] Настройка сохраняется
|
||||||
|
|
||||||
|
- [ ] 📋 Список всех розыгрышей
|
||||||
|
- [ ] Показывает все розыгрыши (активные и завершенные)
|
||||||
|
- [ ] Для каждого розыгрыша показывается:
|
||||||
|
- [ ] Название
|
||||||
|
- [ ] Статус (активный/завершенный)
|
||||||
|
- [ ] Количество участников
|
||||||
|
- [ ] Дата создания
|
||||||
|
|
||||||
|
- [ ] 🏁 Завершить розыгрыш
|
||||||
|
- [ ] Показывает список активных розыгрышей
|
||||||
|
- [ ] Выбор розыгрыша завершает его
|
||||||
|
- [ ] Запрос подтверждения
|
||||||
|
- [ ] Статус меняется в БД
|
||||||
|
|
||||||
|
- [ ] 🗑️ Удалить розыгрыш
|
||||||
|
- [ ] Показывает список всех розыгрышей
|
||||||
|
- [ ] Выбор розыгрыша запрашивает подтверждение
|
||||||
|
- [ ] Розыгрыш удаляется из БД
|
||||||
|
|
||||||
|
- [ ] 🔙 Назад - возврат в главное меню админ панели
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Раздел: Управление участниками
|
||||||
|
|
||||||
|
### Открытие раздела:
|
||||||
|
- [ ] Нажать "👥 Управление участниками" в админ панели
|
||||||
|
- [ ] Проверить отображение всех 9 кнопок
|
||||||
|
|
||||||
|
### Кнопки раздела:
|
||||||
|
- [ ] ➕ Добавить участника
|
||||||
|
- [ ] Выбор розыгрыша
|
||||||
|
- [ ] Выбор пользователя (по ID или имени)
|
||||||
|
- [ ] Участник добавляется в розыгрыш
|
||||||
|
- [ ] Проверка дубликатов
|
||||||
|
|
||||||
|
- [ ] 📥 Массовое добавление (ID)
|
||||||
|
- [ ] Выбор розыгрыша
|
||||||
|
- [ ] Ввод списка Telegram ID (через запятую или построчно)
|
||||||
|
- [ ] Массовое добавление участников
|
||||||
|
- [ ] Отчет об успешных/неудачных добавлениях
|
||||||
|
|
||||||
|
- [ ] 🏦 Массовое добавление (счета)
|
||||||
|
- [ ] Выбор розыгрыша
|
||||||
|
- [ ] Ввод списка номеров счетов
|
||||||
|
- [ ] Участники добавляются по номерам счетов
|
||||||
|
- [ ] Отчет об операции
|
||||||
|
|
||||||
|
- [ ] ➖ Удалить участника
|
||||||
|
- [ ] Выбор розыгрыша
|
||||||
|
- [ ] Выбор участника
|
||||||
|
- [ ] Участник удаляется
|
||||||
|
- [ ] Подтверждение удаления
|
||||||
|
|
||||||
|
- [ ] 📤 Массовое удаление (ID)
|
||||||
|
- [ ] Выбор розыгрыша
|
||||||
|
- [ ] Ввод списка ID для удаления
|
||||||
|
- [ ] Массовое удаление
|
||||||
|
- [ ] Отчет об операции
|
||||||
|
|
||||||
|
- [ ] 🏦 Массовое удаление (счета)
|
||||||
|
- [ ] Выбор розыгрыша
|
||||||
|
- [ ] Ввод списка номеров счетов
|
||||||
|
- [ ] Удаление по счетам
|
||||||
|
- [ ] Отчет об операции
|
||||||
|
|
||||||
|
- [ ] 👥 Все участники
|
||||||
|
- [ ] Показывает список всех зарегистрированных пользователей
|
||||||
|
- [ ] Пагинация (если много)
|
||||||
|
- [ ] Показывает ID, имя, статус регистрации
|
||||||
|
|
||||||
|
- [ ] 🔍 Поиск участников
|
||||||
|
- [ ] Поиск по имени
|
||||||
|
- [ ] Поиск по Telegram ID
|
||||||
|
- [ ] Поиск по номеру счета
|
||||||
|
- [ ] Показывает результаты поиска
|
||||||
|
|
||||||
|
- [ ] 📊 Участники по розыгрышам
|
||||||
|
- [ ] Выбор розыгрыша
|
||||||
|
- [ ] Показывает всех участников розыгрыша
|
||||||
|
- [ ] Количество участников
|
||||||
|
- [ ] Список с именами/ID/счетами
|
||||||
|
|
||||||
|
- [ ] 📈 Отчет по участникам
|
||||||
|
- [ ] Детальная статистика по участиям
|
||||||
|
- [ ] Топ участников (по количеству участий)
|
||||||
|
- [ ] Распределение по розыгрышам
|
||||||
|
|
||||||
|
- [ ] 🔙 Назад - возврат в главное меню админ панели
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Раздел: Управление победителями
|
||||||
|
|
||||||
|
### Открытие раздела:
|
||||||
|
- [ ] Нажать "👑 Управление победителями" в админ панели
|
||||||
|
- [ ] Проверить отображение всех 6 кнопок
|
||||||
|
|
||||||
|
### Кнопки раздела:
|
||||||
|
- [ ] 👑 Установить победителя
|
||||||
|
- [ ] Выбор розыгрыша
|
||||||
|
- [ ] Выбор места (1, 2, 3...)
|
||||||
|
- [ ] Выбор пользователя вручную
|
||||||
|
- [ ] Победитель сохраняется в БД
|
||||||
|
|
||||||
|
- [ ] 📝 Изменить победителя
|
||||||
|
- [ ] Показывает список розыгрышей с победителями
|
||||||
|
- [ ] Выбор победителя для изменения
|
||||||
|
- [ ] Возможность изменить место или пользователя
|
||||||
|
- [ ] Изменения сохраняются
|
||||||
|
|
||||||
|
- [ ] ❌ Удалить победителя
|
||||||
|
- [ ] Показывает список победителей
|
||||||
|
- [ ] Выбор победителя
|
||||||
|
- [ ] Подтверждение удаления
|
||||||
|
- [ ] Победитель удаляется из БД
|
||||||
|
|
||||||
|
- [ ] 📋 Список победителей
|
||||||
|
- [ ] Показывает всех победителей всех розыгрышей
|
||||||
|
- [ ] Группировка по розыгрышам
|
||||||
|
- [ ] Место, имя/счет, приз
|
||||||
|
|
||||||
|
- [ ] 🎲 Провести розыгрыш
|
||||||
|
- [ ] Выбор розыгрыша
|
||||||
|
- [ ] Автоматическое определение победителей
|
||||||
|
- [ ] Случайный выбор из участников
|
||||||
|
- [ ] Сохранение результатов
|
||||||
|
- [ ] Уведомление победителей
|
||||||
|
|
||||||
|
- [ ] 🔙 Назад - возврат в главное меню админ панели
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Раздел: Статистика
|
||||||
|
|
||||||
|
### Открытие раздела:
|
||||||
|
- [ ] Нажать "📊 Статистика" в админ панели
|
||||||
|
- [ ] Проверить отображение статистики
|
||||||
|
|
||||||
|
### Показываемые данные:
|
||||||
|
- [ ] 👥 Всего пользователей: [число]
|
||||||
|
- [ ] ✅ Зарегистрированных: [число]
|
||||||
|
- [ ] 🎲 Всего розыгрышей: [число]
|
||||||
|
- [ ] 🟢 Активных: [число]
|
||||||
|
- [ ] ✅ Завершенных: [число]
|
||||||
|
- [ ] 🎫 Участий: [число]
|
||||||
|
|
||||||
|
### Кнопки:
|
||||||
|
- [ ] 🔄 Обновить - обновляет статистику
|
||||||
|
- [ ] 🔙 Назад - возврат в админ панель
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Раздел: Настройки
|
||||||
|
|
||||||
|
### Открытие раздела:
|
||||||
|
- [ ] Нажать "⚙️ Настройки" в админ панели
|
||||||
|
- [ ] Проверить доступность настроек
|
||||||
|
|
||||||
|
### Возможности (зависит от реализации):
|
||||||
|
- [ ] Настройки уведомлений
|
||||||
|
- [ ] Экспорт данных
|
||||||
|
- [ ] Очистка старых данных
|
||||||
|
- [ ] Управление администраторами
|
||||||
|
- [ ] Системные настройки
|
||||||
|
|
||||||
|
- [ ] 🔙 Назад - возврат в админ панель
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Проверка прав доступа
|
||||||
|
|
||||||
|
### Для обычных пользователей:
|
||||||
|
- [ ] Кнопка "Админ панель" НЕ показывается
|
||||||
|
- [ ] Попытка прямого вызова admin callback'ов возвращает "❌ Недостаточно прав"
|
||||||
|
|
||||||
|
### Для администраторов:
|
||||||
|
- [ ] Все разделы доступны
|
||||||
|
- [ ] Все операции выполняются
|
||||||
|
- [ ] Статистика отображается корректно
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Проверка навигации
|
||||||
|
|
||||||
|
### Возврат назад:
|
||||||
|
- [ ] Из каждого подраздела можно вернуться в админ панель
|
||||||
|
- [ ] Из админ панели можно вернуться в главное меню
|
||||||
|
- [ ] Кнопки отмены работают во всех FSM состояниях
|
||||||
|
|
||||||
|
### Breadcrumbs (последовательность):
|
||||||
|
1. Главное меню бота
|
||||||
|
2. → Админ панель
|
||||||
|
3. → → Конкретный раздел (розыгрыши/участники/победители)
|
||||||
|
4. → → → Подменю раздела (если есть)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Известные проблемы и их решения
|
||||||
|
|
||||||
|
### Проблема: Кнопка не реагирует
|
||||||
|
**Решение:**
|
||||||
|
1. Проверить логи бота: `tail -f /tmp/bot_single.log`
|
||||||
|
2. Убедиться, что callback_data зарегистрирован в роутере
|
||||||
|
3. Проверить порядок подключения роутеров
|
||||||
|
|
||||||
|
### Проблема: FSM не сохраняет состояние
|
||||||
|
**Решение:**
|
||||||
|
1. Убедиться, что storage настроен (MemoryStorage)
|
||||||
|
2. Проверить вызов `state.set_state()`
|
||||||
|
3. Проверить StateFilter в handler'ах
|
||||||
|
|
||||||
|
### Проблема: "Недостаточно прав" для админа
|
||||||
|
**Решение:**
|
||||||
|
1. Проверить, что ID админа в `ADMIN_IDS` (config.py)
|
||||||
|
2. Проверить формат ID (должен быть int)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Инструкция по тестированию
|
||||||
|
|
||||||
|
### Шаг 1: Подготовка
|
||||||
|
1. Запустить бота: `make bot-start`
|
||||||
|
2. Открыть чат с ботом в Telegram
|
||||||
|
3. Убедиться, что у вас админские права
|
||||||
|
|
||||||
|
### Шаг 2: Тестирование главного меню
|
||||||
|
1. Отправить `/start`
|
||||||
|
2. Проверить все кнопки главного меню
|
||||||
|
3. Проверить кнопку "Админ панель"
|
||||||
|
|
||||||
|
### Шаг 3: Тестирование админ панели
|
||||||
|
1. Открыть админ панель
|
||||||
|
2. Последовательно зайти в каждый раздел
|
||||||
|
3. Проверить все кнопки в каждом разделе
|
||||||
|
4. Отметить работающие кнопки в чеклисте
|
||||||
|
|
||||||
|
### Шаг 4: Тестирование FSM процессов
|
||||||
|
1. Создать новый розыгрыш (полный цикл)
|
||||||
|
2. Добавить участников (разными способами)
|
||||||
|
3. Провести розыгрыш
|
||||||
|
4. Проверить результаты
|
||||||
|
|
||||||
|
### Шаг 5: Проверка навигации
|
||||||
|
1. Из каждого меню вернуться назад
|
||||||
|
2. Проверить корректность возврата
|
||||||
|
3. Убедиться, что нет "мертвых" кнопок
|
||||||
|
|
||||||
|
### Шаг 6: Логи
|
||||||
|
1. Во время тестирования следить за логами
|
||||||
|
2. Фиксировать все ошибки
|
||||||
|
3. Проверять успешное выполнение операций
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Результат тестирования
|
||||||
|
|
||||||
|
**Дата:** [Указать дату]
|
||||||
|
**Тестировщик:** [Указать имя]
|
||||||
|
**Версия бота:** [Указать commit hash]
|
||||||
|
|
||||||
|
### Статистика:
|
||||||
|
- Всего проверено кнопок: ____ / ____
|
||||||
|
- Работает корректно: ____
|
||||||
|
- Требует исправления: ____
|
||||||
|
- Критические ошибки: ____
|
||||||
|
|
||||||
|
### Замечания:
|
||||||
|
[Описать найденные проблемы и рекомендации]
|
||||||
126
CODE_CLEANUP_REPORT.md
Normal file
126
CODE_CLEANUP_REPORT.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Результаты очистки кода
|
||||||
|
|
||||||
|
## Дата: 17 ноября 2025 г.
|
||||||
|
|
||||||
|
### Выполненные действия:
|
||||||
|
|
||||||
|
## 1. Удалены дублирующиеся обработчики из main.py
|
||||||
|
|
||||||
|
**Удалено:**
|
||||||
|
- `test_callback_handler` - тестовый callback (не используется в продакшене)
|
||||||
|
- `admin_panel_handler` - дублируется с admin_panel.py
|
||||||
|
- `lottery_management_handler` - дублируется с admin_panel.py
|
||||||
|
- `conduct_lottery_admin_handler` - дублируется с admin_panel.py
|
||||||
|
- `conduct_specific_lottery_handler` - дублируется с admin_panel.py
|
||||||
|
|
||||||
|
**Оставлено:**
|
||||||
|
- `cmd_start` - обработчик команды /start
|
||||||
|
- `cmd_admin` - упрощен, теперь напрямую показывает админ панель
|
||||||
|
- `active_lotteries_handler` - показ активных розыгрышей
|
||||||
|
- `back_to_main_handler` - возврат в главное меню
|
||||||
|
|
||||||
|
## 2. Очищены методы BotController
|
||||||
|
|
||||||
|
**Удалено из `src/controllers/bot_controller.py`:**
|
||||||
|
- `handle_admin_panel()` - перенесено в admin_panel.py
|
||||||
|
- `handle_lottery_management()` - перенесено в admin_panel.py
|
||||||
|
- `handle_conduct_lottery_admin()` - перенесено в admin_panel.py
|
||||||
|
- `handle_conduct_lottery()` - перенесено в admin_panel.py
|
||||||
|
|
||||||
|
**Оставлено:**
|
||||||
|
- `handle_start()` - обработка команды /start
|
||||||
|
- `handle_active_lotteries()` - показ активных розыгрышей
|
||||||
|
- `is_admin()` - проверка прав администратора
|
||||||
|
|
||||||
|
## 3. Упрощены клавиатуры в ui.py
|
||||||
|
|
||||||
|
**Удалено из `src/components/ui.py`:**
|
||||||
|
- `get_lottery_management_keyboard()` - используется локальная версия в admin_panel.py
|
||||||
|
- `get_lottery_keyboard()` - не используется
|
||||||
|
- `get_conduct_lottery_keyboard()` - не используется
|
||||||
|
|
||||||
|
**Оставлено:**
|
||||||
|
- `get_main_keyboard()` - главная клавиатура бота
|
||||||
|
- `get_admin_keyboard()` - админская панель
|
||||||
|
|
||||||
|
## 4. Упрощены интерфейсы
|
||||||
|
|
||||||
|
**IBotController (`src/interfaces/base.py`):**
|
||||||
|
- Было: 6 методов
|
||||||
|
- Стало: 2 метода
|
||||||
|
- `handle_start()`
|
||||||
|
- `handle_active_lotteries()`
|
||||||
|
|
||||||
|
**IKeyboardBuilder (`src/interfaces/base.py`):**
|
||||||
|
- Было: 5 методов
|
||||||
|
- Стало: 2 метода
|
||||||
|
- `get_main_keyboard()`
|
||||||
|
- `get_admin_keyboard()`
|
||||||
|
|
||||||
|
## 5. Централизация логики
|
||||||
|
|
||||||
|
### Теперь вся админская логика находится в одном месте:
|
||||||
|
- **`src/handlers/admin_panel.py`** - все обработчики админ панели
|
||||||
|
- Создание розыгрышей
|
||||||
|
- Управление участниками
|
||||||
|
- Управление победителями
|
||||||
|
- Статистика
|
||||||
|
- Настройки
|
||||||
|
|
||||||
|
### Разделение ответственности:
|
||||||
|
|
||||||
|
**main.py** - основной роутер:
|
||||||
|
- Команды `/start`, `/admin`
|
||||||
|
- Базовые callback'и (`active_lotteries`, `back_to_main`)
|
||||||
|
|
||||||
|
**admin_panel.py** - админ роутер:
|
||||||
|
- Все callback'и начинающиеся с `admin_*`
|
||||||
|
- FSM состояния для многошаговых операций
|
||||||
|
- Вся логика управления
|
||||||
|
|
||||||
|
**bot_controller.py** - бизнес-логика:
|
||||||
|
- Работа с сервисами
|
||||||
|
- Форматирование данных
|
||||||
|
- Проверка прав доступа
|
||||||
|
|
||||||
|
**ui.py** - UI компоненты:
|
||||||
|
- Построение клавиатур
|
||||||
|
- Форматирование сообщений
|
||||||
|
|
||||||
|
## 6. Результаты
|
||||||
|
|
||||||
|
### Статистика удалений:
|
||||||
|
- **Удалено строк кода:** 202
|
||||||
|
- **Добавлено строк:** 21
|
||||||
|
- **Чистый результат:** -181 строка
|
||||||
|
|
||||||
|
### Улучшения:
|
||||||
|
✅ Нет дублирующегося кода
|
||||||
|
✅ Четкое разделение ответственности
|
||||||
|
✅ Упрощенные интерфейсы
|
||||||
|
✅ Лучшая поддерживаемость
|
||||||
|
✅ Меньше потенциальных багов
|
||||||
|
|
||||||
|
### Тестирование:
|
||||||
|
✅ Бот запускается без ошибок (PID: 802748)
|
||||||
|
✅ Все роутеры подключены правильно
|
||||||
|
✅ Логика админ панели централизована
|
||||||
|
|
||||||
|
## 7. Коммиты
|
||||||
|
|
||||||
|
1. **0fdf01d** - feat: update admin panel keyboard structure and registration button logic
|
||||||
|
2. **43d46ea** - refactor: clean up unused code and duplicate handlers
|
||||||
|
|
||||||
|
## 8. Следующие шаги (рекомендации)
|
||||||
|
|
||||||
|
1. Протестировать все кнопки админ панели через Telegram
|
||||||
|
2. Использовать `ADMIN_PANEL_TESTING.md` как чек-лист
|
||||||
|
3. Проверить работу FSM состояний
|
||||||
|
4. Убедиться в корректности навигации
|
||||||
|
|
||||||
|
## 9. Примечания
|
||||||
|
|
||||||
|
- Все изменения обратно совместимы
|
||||||
|
- Логика работы не изменилась, только структура
|
||||||
|
- Бот работает стабильно
|
||||||
|
- Код стал чище и понятнее
|
||||||
281
DOCKER_DEPLOY.md
Normal file
281
DOCKER_DEPLOY.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# 🐳 Docker Deployment Guide
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### 1. Настройка окружения
|
||||||
|
```bash
|
||||||
|
make docker-setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Отредактируйте `.env.prod` и укажите:
|
||||||
|
- `BOT_TOKEN` - токен от @BotFather
|
||||||
|
- `POSTGRES_PASSWORD` - надежный пароль для БД
|
||||||
|
- `DATABASE_URL` - обновите пароль в строке подключения
|
||||||
|
- `ADMIN_IDS` - ваш Telegram ID
|
||||||
|
|
||||||
|
### 2. Развертывание
|
||||||
|
```bash
|
||||||
|
# Автоматическое развертывание
|
||||||
|
make docker-deploy
|
||||||
|
|
||||||
|
# Или вручную:
|
||||||
|
make docker-build
|
||||||
|
make docker-up
|
||||||
|
make docker-db-migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Проверка
|
||||||
|
```bash
|
||||||
|
make docker-status
|
||||||
|
make docker-logs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Основные команды
|
||||||
|
|
||||||
|
### Управление контейнерами
|
||||||
|
```bash
|
||||||
|
make docker-up # Запустить контейнеры
|
||||||
|
make docker-down # Остановить контейнеры
|
||||||
|
make docker-restart # Перезапустить контейнеры
|
||||||
|
make docker-status # Статус контейнеров
|
||||||
|
```
|
||||||
|
|
||||||
|
### Просмотр логов
|
||||||
|
```bash
|
||||||
|
make docker-logs # Логи бота (с отслеживанием)
|
||||||
|
make docker-logs-db # Логи базы данных
|
||||||
|
make docker-logs-all # Все логи
|
||||||
|
```
|
||||||
|
|
||||||
|
### База данных
|
||||||
|
```bash
|
||||||
|
make docker-db-migrate # Применить миграции
|
||||||
|
make docker-db-shell # Подключиться к PostgreSQL
|
||||||
|
make docker-db-backup # Создать бэкап
|
||||||
|
make docker-db-restore BACKUP=backups/backup_20231115.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Разработка
|
||||||
|
```bash
|
||||||
|
make docker-shell # Открыть shell в контейнере бота
|
||||||
|
make docker-rebuild # Пересобрать и перезапустить
|
||||||
|
```
|
||||||
|
|
||||||
|
### Очистка
|
||||||
|
```bash
|
||||||
|
make docker-clean # Удалить контейнеры
|
||||||
|
make docker-prune # Полная очистка (включая volumes)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
lottery_bot/
|
||||||
|
├── Dockerfile # Образ бота
|
||||||
|
├── docker-compose.yml # Оркестрация контейнеров
|
||||||
|
├── .env.prod # Продакшн-конфигурация (НЕ коммитить!)
|
||||||
|
├── .env.prod.example # Пример конфигурации
|
||||||
|
├── .dockerignore # Исключения для Docker
|
||||||
|
├── deploy.sh # Скрипт автоматического развертывания
|
||||||
|
├── logs/ # Логи (монтируется из контейнера)
|
||||||
|
├── backups/ # Бэкапы БД
|
||||||
|
└── data/ # Данные приложения
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
### Контейнеры
|
||||||
|
|
||||||
|
**bot** - Telegram бот
|
||||||
|
- Образ: Собирается из `Dockerfile`
|
||||||
|
- Restart: unless-stopped
|
||||||
|
- Зависимости: db
|
||||||
|
- Health check: Python проверка
|
||||||
|
|
||||||
|
**db** - PostgreSQL база данных
|
||||||
|
- Образ: postgres:15-alpine
|
||||||
|
- Restart: unless-stopped
|
||||||
|
- Порты: 5432:5432
|
||||||
|
- Volume: postgres_data
|
||||||
|
- Health check: pg_isready
|
||||||
|
|
||||||
|
### Volumes
|
||||||
|
- `postgres_data` - Данные PostgreSQL (персистентные)
|
||||||
|
- `bot_data` - Данные приложения
|
||||||
|
|
||||||
|
### Networks
|
||||||
|
- `lottery_network` - Внутренняя сеть для связи контейнеров
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Мониторинг
|
||||||
|
|
||||||
|
### Статус контейнеров
|
||||||
|
```bash
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Ожидаемый вывод:
|
||||||
|
# lottery_bot running 0.0.0.0:->
|
||||||
|
# lottery_db running 0.0.0.0:5432->5432/tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Логи в реальном времени
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f bot
|
||||||
|
```
|
||||||
|
|
||||||
|
### Использование ресурсов
|
||||||
|
```bash
|
||||||
|
docker stats lottery_bot lottery_db
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Бэкапы
|
||||||
|
|
||||||
|
### Автоматический бэкап
|
||||||
|
```bash
|
||||||
|
# Создать бэкап с временной меткой
|
||||||
|
make docker-db-backup
|
||||||
|
|
||||||
|
# Файл будет сохранен в:
|
||||||
|
# backups/backup_YYYYMMDD_HHMMSS.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Восстановление
|
||||||
|
```bash
|
||||||
|
make docker-db-restore BACKUP=backups/backup_20231115_120000.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Настройка автоматических бэкапов (cron)
|
||||||
|
```bash
|
||||||
|
# Добавьте в crontab:
|
||||||
|
0 2 * * * cd /path/to/lottery_bot && make docker-db-backup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Обновление
|
||||||
|
|
||||||
|
### Обновление кода
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
make docker-rebuild
|
||||||
|
```
|
||||||
|
|
||||||
|
### Применение миграций
|
||||||
|
```bash
|
||||||
|
make docker-db-migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Контейнер не запускается
|
||||||
|
```bash
|
||||||
|
# Проверьте логи
|
||||||
|
make docker-logs
|
||||||
|
|
||||||
|
# Проверьте конфигурацию
|
||||||
|
cat .env.prod
|
||||||
|
|
||||||
|
# Пересоберите образ
|
||||||
|
make docker-rebuild
|
||||||
|
```
|
||||||
|
|
||||||
|
### База данных недоступна
|
||||||
|
```bash
|
||||||
|
# Проверьте статус БД
|
||||||
|
docker-compose ps db
|
||||||
|
|
||||||
|
# Проверьте логи БД
|
||||||
|
make docker-logs-db
|
||||||
|
|
||||||
|
# Подключитесь к БД напрямую
|
||||||
|
make docker-db-shell
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проблемы с миграциями
|
||||||
|
```bash
|
||||||
|
# Проверьте текущую версию
|
||||||
|
docker-compose exec bot alembic current
|
||||||
|
|
||||||
|
# Откатите миграцию
|
||||||
|
docker-compose exec bot alembic downgrade -1
|
||||||
|
|
||||||
|
# Примените снова
|
||||||
|
make docker-db-migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Высокое потребление ресурсов
|
||||||
|
```bash
|
||||||
|
# Проверьте использование
|
||||||
|
docker stats
|
||||||
|
|
||||||
|
# Ограничьте ресурсы в docker-compose.yml:
|
||||||
|
services:
|
||||||
|
bot:
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 512M
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
### Рекомендации
|
||||||
|
|
||||||
|
1. **Пароли**
|
||||||
|
- Используйте надежные пароли в `.env.prod`
|
||||||
|
- Не коммитьте `.env.prod` в Git
|
||||||
|
|
||||||
|
2. **Порты**
|
||||||
|
- Закройте порт 5432 если БД не нужна извне
|
||||||
|
- Используйте firewall для ограничения доступа
|
||||||
|
|
||||||
|
3. **Обновления**
|
||||||
|
- Регулярно обновляйте образы:
|
||||||
|
```bash
|
||||||
|
docker-compose pull
|
||||||
|
make docker-rebuild
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Логи**
|
||||||
|
- Ротация логов в production
|
||||||
|
- Настройте logrotate для `/home/trevor/new_lottery_bot/logs/`
|
||||||
|
|
||||||
|
5. **Бэкапы**
|
||||||
|
- Автоматические ежедневные бэкапы
|
||||||
|
- Храните бэкапы в безопасном месте
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Checklist
|
||||||
|
|
||||||
|
- [ ] Отредактирован `.env.prod` с реальными значениями
|
||||||
|
- [ ] Установлены надежные пароли
|
||||||
|
- [ ] Настроены автоматические бэкапы
|
||||||
|
- [ ] Настроен мониторинг и алерты
|
||||||
|
- [ ] Настроена ротация логов
|
||||||
|
- [ ] Закрыты неиспользуемые порты
|
||||||
|
- [ ] Протестирован процесс восстановления из бэкапа
|
||||||
|
- [ ] Документированы учетные данные администраторов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Полезные ссылки
|
||||||
|
|
||||||
|
- [Docker Documentation](https://docs.docker.com/)
|
||||||
|
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
||||||
|
- [PostgreSQL Docker Hub](https://hub.docker.com/_/postgres)
|
||||||
|
- [Alembic Documentation](https://alembic.sqlalchemy.org/)
|
||||||
217
Makefile
217
Makefile
@@ -149,4 +149,219 @@ reset: clean
|
|||||||
@echo "🔄 Полная переустановка..."
|
@echo "🔄 Полная переустановка..."
|
||||||
rm -f *.db *.sqlite *.sqlite3
|
rm -f *.db *.sqlite *.sqlite3
|
||||||
rm -rf migrations/versions/*.py
|
rm -rf migrations/versions/*.py
|
||||||
make setup
|
make setup
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 🐳 Docker команды для продакшн
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Показать справку по Docker командам
|
||||||
|
docker-help:
|
||||||
|
@echo "🐳 Docker команды для продакшн-развертывания"
|
||||||
|
@echo "=============================================="
|
||||||
|
@echo ""
|
||||||
|
@echo "Настройка:"
|
||||||
|
@echo " make docker-setup - Первоначальная настройка (создать .env.prod)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Сборка и запуск:"
|
||||||
|
@echo " make docker-build - Собрать Docker образ"
|
||||||
|
@echo " make docker-up - Запустить контейнеры (фоновый режим)"
|
||||||
|
@echo " make docker-up-fg - Запустить контейнеры (с логами)"
|
||||||
|
@echo " make docker-down - Остановить контейнеры"
|
||||||
|
@echo " make docker-restart - Перезапустить контейнеры"
|
||||||
|
@echo ""
|
||||||
|
@echo "Управление:"
|
||||||
|
@echo " make docker-logs - Показать логи бота"
|
||||||
|
@echo " make docker-logs-db - Показать логи БД"
|
||||||
|
@echo " make docker-logs-all - Показать все логи"
|
||||||
|
@echo " make docker-status - Статус контейнеров"
|
||||||
|
@echo " make docker-ps - Список запущенных контейнеров"
|
||||||
|
@echo ""
|
||||||
|
@echo "База данных:"
|
||||||
|
@echo " make docker-db-migrate - Применить миграции в контейнере"
|
||||||
|
@echo " make docker-db-shell - Подключиться к PostgreSQL"
|
||||||
|
@echo " make docker-db-backup - Создать бэкап базы данных"
|
||||||
|
@echo " make docker-db-restore - Восстановить из бэкапа"
|
||||||
|
@echo ""
|
||||||
|
@echo "Очистка:"
|
||||||
|
@echo " make docker-clean - Остановить и удалить контейнеры"
|
||||||
|
@echo " make docker-prune - Полная очистка (включая volumes)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Разработка:"
|
||||||
|
@echo " make docker-shell - Открыть shell в контейнере бота"
|
||||||
|
@echo " make docker-rebuild - Пересобрать и перезапустить"
|
||||||
|
|
||||||
|
# Первоначальная настройка Docker окружения
|
||||||
|
docker-setup:
|
||||||
|
@echo "🔧 Настройка Docker окружения..."
|
||||||
|
@if [ ! -f .env.prod ]; then \
|
||||||
|
if [ -f .env.prod.example ]; then \
|
||||||
|
echo "📄 Создание .env.prod из примера..."; \
|
||||||
|
cp .env.prod.example .env.prod; \
|
||||||
|
echo "⚠️ ВНИМАНИЕ: Отредактируйте .env.prod и укажите реальные значения!"; \
|
||||||
|
echo " - BOT_TOKEN"; \
|
||||||
|
echo " - POSTGRES_PASSWORD"; \
|
||||||
|
echo " - DATABASE_URL"; \
|
||||||
|
echo " - ADMIN_IDS"; \
|
||||||
|
else \
|
||||||
|
echo "❌ Файл .env.prod.example не найден!"; \
|
||||||
|
exit 1; \
|
||||||
|
fi \
|
||||||
|
else \
|
||||||
|
echo "✅ Файл .env.prod уже существует"; \
|
||||||
|
fi
|
||||||
|
@mkdir -p logs backups
|
||||||
|
@echo "✅ Настройка завершена!"
|
||||||
|
|
||||||
|
# Сборка Docker образа
|
||||||
|
docker-build:
|
||||||
|
@echo "🔨 Сборка Docker образа..."
|
||||||
|
docker-compose build --no-cache
|
||||||
|
|
||||||
|
# Запуск контейнеров в фоновом режиме
|
||||||
|
docker-up:
|
||||||
|
@echo "🚀 Запуск контейнеров..."
|
||||||
|
@if [ ! -f .env.prod ]; then \
|
||||||
|
echo "❌ Файл .env.prod не найден! Запустите 'make docker-setup'"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
docker-compose --env-file .env.prod up -d
|
||||||
|
@echo "✅ Контейнеры запущены!"
|
||||||
|
@echo "📊 Проверьте статус: make docker-status"
|
||||||
|
@echo "📋 Просмотр логов: make docker-logs"
|
||||||
|
|
||||||
|
# Запуск контейнеров с выводом логов
|
||||||
|
docker-up-fg:
|
||||||
|
@echo "🚀 Запуск контейнеров с логами..."
|
||||||
|
@if [ ! -f .env.prod ]; then \
|
||||||
|
echo "❌ Файл .env.prod не найден! Запустите 'make docker-setup'"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
docker-compose --env-file .env.prod up
|
||||||
|
|
||||||
|
# Остановка контейнеров
|
||||||
|
docker-down:
|
||||||
|
@echo "🛑 Остановка контейнеров..."
|
||||||
|
docker-compose down
|
||||||
|
@echo "✅ Контейнеры остановлены!"
|
||||||
|
|
||||||
|
# Перезапуск контейнеров
|
||||||
|
docker-restart:
|
||||||
|
@echo "🔄 Перезапуск контейнеров..."
|
||||||
|
docker-compose restart
|
||||||
|
@echo "✅ Контейнеры перезапущены!"
|
||||||
|
|
||||||
|
# Просмотр логов бота
|
||||||
|
docker-logs:
|
||||||
|
@echo "📋 Логи бота..."
|
||||||
|
docker-compose logs -f bot
|
||||||
|
|
||||||
|
# Просмотр логов базы данных
|
||||||
|
docker-logs-db:
|
||||||
|
@echo "📋 Логи базы данных..."
|
||||||
|
docker-compose logs -f db
|
||||||
|
|
||||||
|
# Просмотр всех логов
|
||||||
|
docker-logs-all:
|
||||||
|
@echo "📋 Все логи..."
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Статус контейнеров
|
||||||
|
docker-status:
|
||||||
|
@echo "📊 Статус контейнеров..."
|
||||||
|
@docker-compose ps
|
||||||
|
@echo ""
|
||||||
|
@echo "💾 Использование volumes:"
|
||||||
|
@docker volume ls | grep lottery || echo "Нет volumes"
|
||||||
|
|
||||||
|
# Список запущенных контейнеров
|
||||||
|
docker-ps:
|
||||||
|
@docker ps --filter "name=lottery"
|
||||||
|
|
||||||
|
# Применение миграций в контейнере
|
||||||
|
docker-db-migrate:
|
||||||
|
@echo "⬆️ Применение миграций в контейнере..."
|
||||||
|
docker-compose exec bot alembic upgrade head
|
||||||
|
@echo "✅ Миграции применены!"
|
||||||
|
|
||||||
|
# Подключение к PostgreSQL в контейнере
|
||||||
|
docker-db-shell:
|
||||||
|
@echo "🐘 Подключение к PostgreSQL..."
|
||||||
|
@docker-compose exec db psql -U $${POSTGRES_USER:-lottery_user} -d $${POSTGRES_DB:-lottery_bot_db}
|
||||||
|
|
||||||
|
# Создание бэкапа базы данных
|
||||||
|
docker-db-backup:
|
||||||
|
@echo "💾 Создание бэкапа базы данных..."
|
||||||
|
@mkdir -p backups
|
||||||
|
@BACKUP_FILE=backups/backup_$$(date +%Y%m%d_%H%M%S).sql; \
|
||||||
|
docker-compose exec -T db pg_dump -U $${POSTGRES_USER:-lottery_user} $${POSTGRES_DB:-lottery_bot_db} > $$BACKUP_FILE && \
|
||||||
|
echo "✅ Бэкап создан: $$BACKUP_FILE"
|
||||||
|
|
||||||
|
# Восстановление из бэкапа
|
||||||
|
docker-db-restore:
|
||||||
|
@echo "⚠️ Восстановление базы данных из бэкапа"
|
||||||
|
@if [ -z "$(BACKUP)" ]; then \
|
||||||
|
echo "❌ Укажите файл бэкапа: make docker-db-restore BACKUP=backups/backup_20231115_120000.sql"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@echo "Восстановление из: $(BACKUP)"
|
||||||
|
@read -p "Это удалит текущие данные! Продолжить? [y/N] " confirm; \
|
||||||
|
if [ "$$confirm" = "y" ] || [ "$$confirm" = "Y" ]; then \
|
||||||
|
cat $(BACKUP) | docker-compose exec -T db psql -U $${POSTGRES_USER:-lottery_user} $${POSTGRES_DB:-lottery_bot_db}; \
|
||||||
|
echo "✅ База данных восстановлена!"; \
|
||||||
|
else \
|
||||||
|
echo "❌ Отменено"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Открыть shell в контейнере бота
|
||||||
|
docker-shell:
|
||||||
|
@echo "🐚 Открытие shell в контейнере бота..."
|
||||||
|
docker-compose exec bot /bin/bash
|
||||||
|
|
||||||
|
# Остановка и удаление контейнеров
|
||||||
|
docker-clean:
|
||||||
|
@echo "🧹 Очистка контейнеров..."
|
||||||
|
docker-compose down --remove-orphans
|
||||||
|
@echo "✅ Контейнеры удалены!"
|
||||||
|
|
||||||
|
# Полная очистка (включая volumes)
|
||||||
|
docker-prune:
|
||||||
|
@echo "⚠️ ВНИМАНИЕ! Это удалит ВСЕ данные Docker (включая БД)!"
|
||||||
|
@read -p "Продолжить? [y/N] " confirm; \
|
||||||
|
if [ "$$confirm" = "y" ] || [ "$$confirm" = "Y" ]; then \
|
||||||
|
echo "🗑️ Полная очистка..."; \
|
||||||
|
docker-compose down -v --remove-orphans; \
|
||||||
|
docker system prune -f; \
|
||||||
|
echo "✅ Очистка завершена!"; \
|
||||||
|
else \
|
||||||
|
echo "❌ Отменено"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Пересборка и перезапуск
|
||||||
|
docker-rebuild:
|
||||||
|
@echo "🔄 Пересборка и перезапуск..."
|
||||||
|
docker-compose down
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose --env-file .env.prod up -d
|
||||||
|
@echo "✅ Готово!"
|
||||||
|
@make docker-logs
|
||||||
|
|
||||||
|
# Быстрое развертывание с нуля
|
||||||
|
docker-deploy:
|
||||||
|
@echo "🚀 Полное развертывание в продакшн..."
|
||||||
|
@make docker-setup
|
||||||
|
@echo ""
|
||||||
|
@echo "⚠️ Перед продолжением убедитесь, что отредактировали .env.prod!"
|
||||||
|
@read -p "Продолжить развертывание? [y/N] " confirm; \
|
||||||
|
if [ "$$confirm" = "y" ] || [ "$$confirm" = "Y" ]; then \
|
||||||
|
make docker-build; \
|
||||||
|
make docker-up; \
|
||||||
|
sleep 5; \
|
||||||
|
make docker-db-migrate; \
|
||||||
|
echo ""; \
|
||||||
|
echo "✅ Развертывание завершено!"; \
|
||||||
|
echo "📊 Статус:"; \
|
||||||
|
make docker-status; \
|
||||||
|
else \
|
||||||
|
echo "❌ Отменено"; \
|
||||||
|
fi
|
||||||
46
QUICK_START.txt
Normal file
46
QUICK_START.txt
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
╔════════════════════════════════════════════════════════════════╗
|
||||||
|
║ 🤖 УПРАВЛЕНИЕ БОТОМ - ШПАРГАЛКА ║
|
||||||
|
╚════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
⚡ БЫСТРЫЕ КОМАНДЫ:
|
||||||
|
|
||||||
|
make bot-start → Запустить бота
|
||||||
|
make bot-stop → Остановить бота
|
||||||
|
make bot-restart → Перезапустить бота
|
||||||
|
make bot-status → Проверить состояние
|
||||||
|
make bot-logs → Смотреть логи (Ctrl+C для выхода)
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
⚠️ ПРОБЛЕМА: Бот не реагирует на команды?
|
||||||
|
|
||||||
|
ПРИЧИНА: Запущено несколько экземпляров бота одновременно
|
||||||
|
|
||||||
|
РЕШЕНИЕ:
|
||||||
|
1. make bot-restart (перезапустит правильно)
|
||||||
|
2. make bot-status (проверит что запущен только один)
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
🔍 ДИАГНОСТИКА:
|
||||||
|
|
||||||
|
Проверить процессы:
|
||||||
|
ps aux | grep "python main.py" | grep -v grep
|
||||||
|
|
||||||
|
(Должна быть ОДНА строка!)
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
📁 ФАЙЛЫ:
|
||||||
|
|
||||||
|
Логи: /tmp/bot_single.log
|
||||||
|
PID: .bot.pid
|
||||||
|
Скрипт: ./bot_control.sh
|
||||||
|
Документ: BOT_MANAGEMENT.md
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
❌ НИКОГДА НЕ ИСПОЛЬЗУЙ: make run (для продакшена)
|
||||||
|
✅ ВСЕГДА ИСПОЛЬЗУЙ: make bot-start
|
||||||
|
|
||||||
|
╚════════════════════════════════════════════════════════════════╝
|
||||||
101
deploy.sh
Executable file
101
deploy.sh
Executable file
@@ -0,0 +1,101 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт быстрого развертывания бота в продакшн через Docker
|
||||||
|
|
||||||
|
set -e # Остановка при ошибке
|
||||||
|
|
||||||
|
echo "🚀 Быстрое развертывание lottery bot в продакшн"
|
||||||
|
echo "================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверка наличия Docker
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "❌ Docker не установлен!"
|
||||||
|
echo "Установите Docker: https://docs.docker.com/get-docker/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Проверка наличия Docker Compose
|
||||||
|
if ! command -v docker-compose &> /dev/null; then
|
||||||
|
echo "❌ Docker Compose не установлен!"
|
||||||
|
echo "Установите Docker Compose: https://docs.docker.com/compose/install/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Docker и Docker Compose установлены"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверка .env.prod
|
||||||
|
if [ ! -f .env.prod ]; then
|
||||||
|
echo "⚠️ Файл .env.prod не найден"
|
||||||
|
|
||||||
|
if [ -f .env.prod.example ]; then
|
||||||
|
echo "📄 Создаю .env.prod из примера..."
|
||||||
|
cp .env.prod.example .env.prod
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ ВНИМАНИЕ!"
|
||||||
|
echo "Отредактируйте файл .env.prod и укажите:"
|
||||||
|
echo " - BOT_TOKEN (токен от @BotFather)"
|
||||||
|
echo " - POSTGRES_PASSWORD (надежный пароль для БД)"
|
||||||
|
echo " - DATABASE_URL (обновите пароль в строке подключения)"
|
||||||
|
echo " - ADMIN_IDS (ваш Telegram ID)"
|
||||||
|
echo ""
|
||||||
|
read -p "Нажмите Enter после редактирования .env.prod..."
|
||||||
|
else
|
||||||
|
echo "❌ Файл .env.prod.example не найден!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Конфигурация найдена"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Создание необходимых директорий
|
||||||
|
echo "📁 Создание директорий..."
|
||||||
|
mkdir -p logs backups data
|
||||||
|
echo "✅ Директории созданы"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Сборка образа
|
||||||
|
echo "🔨 Сборка Docker образа..."
|
||||||
|
docker-compose build --no-cache
|
||||||
|
echo "✅ Образ собран"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Запуск контейнеров
|
||||||
|
echo "🚀 Запуск контейнеров..."
|
||||||
|
docker-compose --env-file .env.prod up -d
|
||||||
|
echo "✅ Контейнеры запущены"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Ожидание запуска БД
|
||||||
|
echo "⏳ Ожидание запуска базы данных..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Применение миграций
|
||||||
|
echo "⬆️ Применение миграций..."
|
||||||
|
docker-compose exec -T bot alembic upgrade head || {
|
||||||
|
echo "⚠️ Миграции не применены (возможно БД уже актуальна)"
|
||||||
|
}
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Статус
|
||||||
|
echo "📊 Статус контейнеров:"
|
||||||
|
docker-compose ps
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверка логов
|
||||||
|
echo "📋 Последние логи бота:"
|
||||||
|
docker-compose logs --tail=20 bot
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "✅ Развертывание завершено!"
|
||||||
|
echo ""
|
||||||
|
echo "📝 Полезные команды:"
|
||||||
|
echo " make docker-logs - Просмотр логов"
|
||||||
|
echo " make docker-status - Статус контейнеров"
|
||||||
|
echo " make docker-restart - Перезапуск"
|
||||||
|
echo " make docker-down - Остановка"
|
||||||
|
echo " make docker-db-backup - Бэкап БД"
|
||||||
|
echo ""
|
||||||
|
echo "🎉 Бот запущен и готов к работе!"
|
||||||
@@ -1,137 +1,65 @@
|
|||||||
# Docker Compose для локального тестирования
|
# Docker Compose для продакшн-развертывания
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Основное приложение
|
# Telegram Bot
|
||||||
lottery-bot:
|
bot:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: lottery_bot
|
container_name: lottery_bot
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env.prod
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://lottery:password@postgres:5432/lottery_bot}
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- BOT_TOKEN=${BOT_TOKEN}
|
- BOT_TOKEN=${BOT_TOKEN}
|
||||||
- ADMIN_IDS=${ADMIN_IDS}
|
- ADMIN_IDS=${ADMIN_IDS}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
depends_on:
|
- bot_data:/app/data
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
networks:
|
||||||
- lottery_network
|
- lottery_network
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import sys; sys.exit(0)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
# PostgreSQL база данных
|
# PostgreSQL Database
|
||||||
postgres:
|
db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: lottery_postgres
|
container_name: lottery_db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=lottery_bot
|
POSTGRES_DB: ${POSTGRES_DB:-lottery_bot_db}
|
||||||
- POSTGRES_USER=lottery
|
POSTGRES_USER: ${POSTGRES_USER:-lottery_user}
|
||||||
- POSTGRES_PASSWORD=password
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
ports:
|
PGDATA: /var/lib/postgresql/data/pgdata
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ./scripts/init_postgres.sql:/docker-entrypoint-initdb.d/init.sql
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
networks:
|
||||||
|
- lottery_network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U lottery -d lottery_bot"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lottery_user} -d ${POSTGRES_DB:-lottery_bot_db}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
networks:
|
start_period: 10s
|
||||||
- lottery_network
|
|
||||||
|
|
||||||
# Redis для кэширования
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
container_name: lottery_redis
|
|
||||||
restart: unless-stopped
|
|
||||||
command: redis-server --appendonly yes
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
volumes:
|
|
||||||
- redis_data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
networks:
|
|
||||||
- lottery_network
|
|
||||||
|
|
||||||
# pgAdmin для управления БД (опционально)
|
|
||||||
pgadmin:
|
|
||||||
image: dpage/pgadmin4:latest
|
|
||||||
container_name: lottery_pgadmin
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- PGADMIN_DEFAULT_EMAIL=admin@lottery.local
|
|
||||||
- PGADMIN_DEFAULT_PASSWORD=admin
|
|
||||||
ports:
|
|
||||||
- "8080:80"
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
networks:
|
|
||||||
- lottery_network
|
|
||||||
profiles:
|
|
||||||
- admin
|
|
||||||
|
|
||||||
# Prometheus для мониторинга (опционально)
|
|
||||||
prometheus:
|
|
||||||
image: prom/prometheus:latest
|
|
||||||
container_name: lottery_prometheus
|
|
||||||
restart: unless-stopped
|
|
||||||
command:
|
|
||||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
|
||||||
- '--storage.tsdb.path=/prometheus'
|
|
||||||
- '--web.console.libraries=/etc/prometheus/console_libraries'
|
|
||||||
- '--web.console.templates=/etc/prometheus/consoles'
|
|
||||||
ports:
|
|
||||||
- "9090:9090"
|
|
||||||
volumes:
|
|
||||||
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
|
|
||||||
- prometheus_data:/prometheus
|
|
||||||
networks:
|
|
||||||
- lottery_network
|
|
||||||
profiles:
|
|
||||||
- monitoring
|
|
||||||
|
|
||||||
# Grafana для визуализации (опционально)
|
|
||||||
grafana:
|
|
||||||
image: grafana/grafana:latest
|
|
||||||
container_name: lottery_grafana
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
volumes:
|
|
||||||
- grafana_data:/var/lib/grafana
|
|
||||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning
|
|
||||||
depends_on:
|
|
||||||
- prometheus
|
|
||||||
networks:
|
|
||||||
- lottery_network
|
|
||||||
profiles:
|
|
||||||
- monitoring
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
name: lottery_postgres_data
|
driver: local
|
||||||
redis_data:
|
bot_data:
|
||||||
name: lottery_redis_data
|
driver: local
|
||||||
prometheus_data:
|
|
||||||
name: lottery_prometheus_data
|
|
||||||
grafana_data:
|
|
||||||
name: lottery_grafana_data
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
lottery_network:
|
lottery_network:
|
||||||
name: lottery_network
|
driver: bridge
|
||||||
driver: bridge
|
|
||||||
|
|||||||
76
generate_test_accounts.py
Executable file
76
generate_test_accounts.py
Executable file
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Генератор тестовых счетов для проверки производительности розыгрыша
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
def generate_account_number():
|
||||||
|
"""Генерирует случайный номер счета в формате XX-XX-XX-XX-XX-XX-XX"""
|
||||||
|
parts = []
|
||||||
|
for _ in range(7):
|
||||||
|
part = f"{random.randint(0, 99):02d}"
|
||||||
|
parts.append(part)
|
||||||
|
return "-".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_accounts(count, card_numbers=None):
|
||||||
|
"""
|
||||||
|
Генерирует список уникальных счетов
|
||||||
|
|
||||||
|
Args:
|
||||||
|
count: Количество счетов для генерации
|
||||||
|
card_numbers: Список номеров карт (опционально)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[str]: Список счетов
|
||||||
|
"""
|
||||||
|
accounts = set()
|
||||||
|
|
||||||
|
while len(accounts) < count:
|
||||||
|
account = generate_account_number()
|
||||||
|
|
||||||
|
# Добавляем с картой или без
|
||||||
|
if card_numbers and random.random() > 0.3: # 70% с картой
|
||||||
|
card = random.choice(card_numbers)
|
||||||
|
full_account = f"{card} {account}"
|
||||||
|
else:
|
||||||
|
full_account = account
|
||||||
|
|
||||||
|
accounts.add(full_account)
|
||||||
|
|
||||||
|
return list(accounts)
|
||||||
|
|
||||||
|
|
||||||
|
def save_to_file(accounts, filename):
|
||||||
|
"""Сохраняет счета в файл"""
|
||||||
|
with open(filename, 'w', encoding='utf-8') as f:
|
||||||
|
for account in accounts:
|
||||||
|
f.write(account + '\n')
|
||||||
|
print(f"✅ Сохранено {len(accounts)} счетов в файл {filename}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Главная функция"""
|
||||||
|
print("🎲 Генератор тестовых счетов для розыгрыша\n")
|
||||||
|
|
||||||
|
# Параметры
|
||||||
|
counts = [100, 500, 1000, 2000, 5000]
|
||||||
|
card_numbers = ['2521', '2522', '2523', '2524', '2525']
|
||||||
|
|
||||||
|
for count in counts:
|
||||||
|
print(f"Генерация {count} счетов...")
|
||||||
|
accounts = generate_accounts(count, card_numbers)
|
||||||
|
filename = f"test_accounts_{count}.txt"
|
||||||
|
save_to_file(accounts, filename)
|
||||||
|
|
||||||
|
print("\n✅ Генерация завершена!")
|
||||||
|
print("\nИспользование:")
|
||||||
|
print("1. Скопируйте содержимое нужного файла")
|
||||||
|
print("2. В боте: Управление розыгрышами → Выберите розыгрыш → Участники → Добавить массово")
|
||||||
|
print("3. Вставьте содержимое файла")
|
||||||
|
print("4. Проведите розыгрыш и проверьте время выполнения")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
82
main.py
82
main.py
@@ -22,6 +22,8 @@ from src.handlers.redraw_handlers import router as redraw_router
|
|||||||
from src.handlers.chat_handlers import router as chat_router
|
from src.handlers.chat_handlers import router as chat_router
|
||||||
from src.handlers.admin_chat_handlers import router as admin_chat_router
|
from src.handlers.admin_chat_handlers import router as admin_chat_router
|
||||||
from src.handlers.account_handlers import account_router
|
from src.handlers.account_handlers import account_router
|
||||||
|
from src.handlers.message_management import message_admin_router
|
||||||
|
from src.handlers.p2p_chat import router as p2p_chat_router
|
||||||
|
|
||||||
# Настройка логирования
|
# Настройка логирования
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -57,53 +59,25 @@ async def cmd_start(message: Message):
|
|||||||
|
|
||||||
@router.message(Command("admin"))
|
@router.message(Command("admin"))
|
||||||
async def cmd_admin(message: Message):
|
async def cmd_admin(message: Message):
|
||||||
"""Обработчик команды /admin"""
|
"""Обработчик команды /admin - перенаправляет в admin_panel"""
|
||||||
async with get_controller() as controller:
|
from src.core.config import ADMIN_IDS
|
||||||
if not controller.is_admin(message.from_user.id):
|
if message.from_user.id not in ADMIN_IDS:
|
||||||
await message.answer("❌ Недостаточно прав для доступа к админ панели")
|
await message.answer("❌ Недостаточно прав для доступа к админ панели")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Создаем callback query объект для совместимости
|
# Отправляем сообщение с кнопкой админ панели
|
||||||
from aiogram.types import CallbackQuery
|
from src.components.ui import KeyboardBuilderImpl
|
||||||
fake_callback = CallbackQuery(
|
kb = KeyboardBuilderImpl()
|
||||||
id="admin_cmd",
|
keyboard = kb.get_admin_keyboard()
|
||||||
from_user=message.from_user,
|
|
||||||
chat_instance="admin",
|
text = "⚙️ **Панель администратора**\n\n"
|
||||||
data="admin_panel",
|
text += "Выберите раздел для управления:"
|
||||||
message=message
|
|
||||||
)
|
await message.answer(text, reply_markup=keyboard, parse_mode="Markdown")
|
||||||
await controller.handle_admin_panel(fake_callback)
|
|
||||||
|
|
||||||
|
|
||||||
# === CALLBACK HANDLERS ===
|
# === CALLBACK HANDLERS ===
|
||||||
|
|
||||||
@router.callback_query(F.data == "test_callback")
|
|
||||||
async def test_callback_handler(callback: CallbackQuery):
|
|
||||||
"""Тестовый callback handler"""
|
|
||||||
await callback.answer("✅ Тест прошел успешно! Колбэки работают.", show_alert=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "admin_panel")
|
|
||||||
async def admin_panel_handler(callback: CallbackQuery):
|
|
||||||
"""Обработчик админ панели"""
|
|
||||||
async with get_controller() as controller:
|
|
||||||
await controller.handle_admin_panel(callback)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "lottery_management")
|
|
||||||
async def lottery_management_handler(callback: CallbackQuery):
|
|
||||||
"""Обработчик управления розыгрышами"""
|
|
||||||
async with get_controller() as controller:
|
|
||||||
await controller.handle_lottery_management(callback)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "conduct_lottery_admin")
|
|
||||||
async def conduct_lottery_admin_handler(callback: CallbackQuery):
|
|
||||||
"""Обработчик выбора розыгрыша для проведения"""
|
|
||||||
async with get_controller() as controller:
|
|
||||||
await controller.handle_conduct_lottery_admin(callback)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "active_lotteries")
|
@router.callback_query(F.data == "active_lotteries")
|
||||||
async def active_lotteries_handler(callback: CallbackQuery):
|
async def active_lotteries_handler(callback: CallbackQuery):
|
||||||
"""Обработчик показа активных розыгрышей"""
|
"""Обработчик показа активных розыгрышей"""
|
||||||
@@ -111,23 +85,11 @@ async def active_lotteries_handler(callback: CallbackQuery):
|
|||||||
await controller.handle_active_lotteries(callback)
|
await controller.handle_active_lotteries(callback)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("conduct_") & ~F.data.in_(["conduct_lottery_admin"]))
|
|
||||||
async def conduct_specific_lottery_handler(callback: CallbackQuery):
|
|
||||||
"""Обработчик проведения конкретного розыгрыша"""
|
|
||||||
async with get_controller() as controller:
|
|
||||||
await controller.handle_conduct_lottery(callback)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "back_to_main")
|
@router.callback_query(F.data == "back_to_main")
|
||||||
async def back_to_main_handler(callback: CallbackQuery):
|
async def back_to_main_handler(callback: CallbackQuery):
|
||||||
"""Обработчик возврата в главное меню"""
|
"""Обработчик возврата в главное меню"""
|
||||||
async with get_controller() as controller:
|
async with get_controller() as controller:
|
||||||
await controller.handle_start(callback.message)
|
await controller.handle_start(callback.message)
|
||||||
|
|
||||||
|
|
||||||
# === ЗАГЛУШКИ ДЛЯ ОСТАЛЬНЫХ CALLBACKS ===
|
|
||||||
|
|
||||||
# === ЗАГЛУШКИ НЕ НУЖНЫ - ВСЕ ФУНКЦИИ РЕАЛИЗОВАНЫ В РОУТЕРАХ ===
|
|
||||||
# Функции обрабатываются в:
|
# Функции обрабатываются в:
|
||||||
# - admin_panel.py: создание розыгрышей, управление пользователями, счетами, чатом, статистика
|
# - admin_panel.py: создание розыгрышей, управление пользователями, счетами, чатом, статистика
|
||||||
# - registration_handlers.py: регистрация пользователей
|
# - registration_handlers.py: регистрация пользователей
|
||||||
@@ -149,15 +111,19 @@ async def main():
|
|||||||
dp.include_router(router)
|
dp.include_router(router)
|
||||||
|
|
||||||
# 2. Специфичные роутеры
|
# 2. Специфичные роутеры
|
||||||
|
dp.include_router(message_admin_router) # Управление сообщениями администратором
|
||||||
dp.include_router(admin_router) # Админ панель - самая высокая специфичность
|
dp.include_router(admin_router) # Админ панель - самая высокая специфичность
|
||||||
dp.include_router(registration_router) # Регистрация
|
dp.include_router(registration_router) # Регистрация
|
||||||
dp.include_router(admin_account_router) # Админские команды счетов
|
dp.include_router(admin_account_router) # Админские команды счетов
|
||||||
dp.include_router(admin_chat_router) # Админские команды чата
|
dp.include_router(admin_chat_router) # Админские команды чата
|
||||||
dp.include_router(redraw_router) # Повторные розыгрыши
|
dp.include_router(redraw_router) # Повторные розыгрыши
|
||||||
dp.include_router(account_router) # Пользовательские счета
|
dp.include_router(p2p_chat_router) # P2P чат между пользователями
|
||||||
|
|
||||||
# 3. Chat router ПОСЛЕДНИМ (ловит все необработанные сообщения)
|
# 3. Chat router для broadcast (ловит все необработанные сообщения)
|
||||||
dp.include_router(chat_router) # Пользовательский чат (последним - ловит все сообщения)
|
dp.include_router(chat_router) # Пользовательский чат (broadcast всем)
|
||||||
|
|
||||||
|
# 4. Account router ПОСЛЕДНИМ (обнаружение счетов для админов)
|
||||||
|
dp.include_router(account_router) # Пользовательские счета + обнаружение для админов
|
||||||
|
|
||||||
# Запускаем polling
|
# Запускаем polling
|
||||||
try:
|
try:
|
||||||
|
|||||||
53
migrations/versions/008_add_p2p_messages.py
Normal file
53
migrations/versions/008_add_p2p_messages.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""add p2p messages table
|
||||||
|
|
||||||
|
Revision ID: 008
|
||||||
|
Revises: 007
|
||||||
|
Create Date: 2025-11-17
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision = '008'
|
||||||
|
down_revision = '007'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Создаём таблицу P2P сообщений
|
||||||
|
op.create_table(
|
||||||
|
'p2p_messages',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('sender_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('recipient_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('message_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('text', sa.Text(), nullable=True),
|
||||||
|
sa.Column('file_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('sender_message_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('recipient_message_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('is_read', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('read_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('reply_to_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['recipient_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['reply_to_id'], ['p2p_messages.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Создаём индексы для быстрого поиска
|
||||||
|
op.create_index('ix_p2p_messages_sender_id', 'p2p_messages', ['sender_id'])
|
||||||
|
op.create_index('ix_p2p_messages_recipient_id', 'p2p_messages', ['recipient_id'])
|
||||||
|
op.create_index('ix_p2p_messages_is_read', 'p2p_messages', ['is_read'])
|
||||||
|
op.create_index('ix_p2p_messages_created_at', 'p2p_messages', ['created_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_p2p_messages_created_at', 'p2p_messages')
|
||||||
|
op.drop_index('ix_p2p_messages_is_read', 'p2p_messages')
|
||||||
|
op.drop_index('ix_p2p_messages_recipient_id', 'p2p_messages')
|
||||||
|
op.drop_index('ix_p2p_messages_sender_id', 'p2p_messages')
|
||||||
|
op.drop_table('p2p_messages')
|
||||||
@@ -8,14 +8,16 @@ from src.core.models import Lottery, Winner
|
|||||||
class KeyboardBuilderImpl(IKeyboardBuilder):
|
class KeyboardBuilderImpl(IKeyboardBuilder):
|
||||||
"""Реализация построителя клавиатур"""
|
"""Реализация построителя клавиатур"""
|
||||||
|
|
||||||
def get_main_keyboard(self, is_admin: bool = False):
|
def get_main_keyboard(self, is_admin: bool = False, is_registered: bool = False):
|
||||||
"""Получить главную клавиатуру"""
|
"""Получить главную клавиатуру"""
|
||||||
buttons = [
|
buttons = [
|
||||||
[InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="active_lotteries")],
|
[InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="active_lotteries")]
|
||||||
[InlineKeyboardButton(text="📝 Зарегистрироваться", callback_data="start_registration")],
|
|
||||||
[InlineKeyboardButton(text="🧪 ТЕСТ КОЛБЭК", callback_data="test_callback")]
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Показываем кнопку регистрации только незарегистрированным пользователям (не админам)
|
||||||
|
if not is_admin and not is_registered:
|
||||||
|
buttons.append([InlineKeyboardButton(text="📝 Зарегистрироваться", callback_data="start_registration")])
|
||||||
|
|
||||||
if is_admin:
|
if is_admin:
|
||||||
buttons.extend([
|
buttons.extend([
|
||||||
[InlineKeyboardButton(text="⚙️ Админ панель", callback_data="admin_panel")],
|
[InlineKeyboardButton(text="⚙️ Админ панель", callback_data="admin_panel")],
|
||||||
@@ -27,40 +29,18 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
|
|||||||
def get_admin_keyboard(self):
|
def get_admin_keyboard(self):
|
||||||
"""Получить админскую клавиатуру"""
|
"""Получить админскую клавиатуру"""
|
||||||
buttons = [
|
buttons = [
|
||||||
[
|
[InlineKeyboardButton(text="🎲 Управление розыгрышами", callback_data="admin_lotteries")],
|
||||||
InlineKeyboardButton(text="👥 Пользователи", callback_data="user_management"),
|
[InlineKeyboardButton(text="<EFBFBD> Управление участниками", callback_data="admin_participants")],
|
||||||
InlineKeyboardButton(text="💳 Счета", callback_data="account_management")
|
[InlineKeyboardButton(text="👑 Управление победителями", callback_data="admin_winners")],
|
||||||
],
|
[InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")],
|
||||||
[
|
[InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_settings")],
|
||||||
InlineKeyboardButton(text="🎯 Розыгрыши", callback_data="lottery_management"),
|
|
||||||
InlineKeyboardButton(text="💬 Чат", callback_data="chat_management")
|
|
||||||
],
|
|
||||||
[
|
|
||||||
InlineKeyboardButton(text="📊 Статистика", callback_data="stats"),
|
|
||||||
InlineKeyboardButton(text="⚙️ Настройки", callback_data="settings")
|
|
||||||
],
|
|
||||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")]
|
[InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")]
|
||||||
]
|
]
|
||||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
def get_lottery_management_keyboard(self):
|
|
||||||
"""Получить клавиатуру управления розыгрышами"""
|
class MessageFormatterImpl(IMessageFormatter):
|
||||||
buttons = [
|
"""Реализация форматирования сообщений"""
|
||||||
[
|
|
||||||
InlineKeyboardButton(text="📋 Все розыгрыши", callback_data="all_lotteries"),
|
|
||||||
InlineKeyboardButton(text="🎲 Активные", callback_data="active_lotteries_admin")
|
|
||||||
],
|
|
||||||
[
|
|
||||||
InlineKeyboardButton(text="✅ Завершенные", callback_data="completed_lotteries"),
|
|
||||||
InlineKeyboardButton(text="➕ Создать", callback_data="create_lottery")
|
|
||||||
],
|
|
||||||
[
|
|
||||||
InlineKeyboardButton(text="🎯 Провести розыгрыш", callback_data="conduct_lottery_admin"),
|
|
||||||
InlineKeyboardButton(text="🔄 Переросыгрыш", callback_data="admin_redraw")
|
|
||||||
],
|
|
||||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")]
|
|
||||||
]
|
|
||||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
|
||||||
|
|
||||||
def get_lottery_keyboard(self, lottery_id: int, is_admin: bool = False):
|
def get_lottery_keyboard(self, lottery_id: int, is_admin: bool = False):
|
||||||
"""Получить клавиатуру для конкретного розыгрыша"""
|
"""Получить клавиатуру для конкретного розыгрыша"""
|
||||||
|
|||||||
@@ -49,77 +49,19 @@ class BotController(IBotController):
|
|||||||
else:
|
else:
|
||||||
welcome_text += "📝 Для участия в розыгрышах необходимо зарегистрироваться."
|
welcome_text += "📝 Для участия в розыгрышах необходимо зарегистрироваться."
|
||||||
|
|
||||||
keyboard = self.keyboard_builder.get_main_keyboard(self.is_admin(message.from_user.id))
|
keyboard = self.keyboard_builder.get_main_keyboard(
|
||||||
|
is_admin=self.is_admin(message.from_user.id),
|
||||||
|
is_registered=user.is_registered
|
||||||
|
)
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
welcome_text,
|
welcome_text,
|
||||||
reply_markup=keyboard
|
reply_markup=keyboard
|
||||||
)
|
)
|
||||||
|
|
||||||
async def handle_admin_panel(self, callback: CallbackQuery):
|
|
||||||
"""Обработать админ панель"""
|
|
||||||
if not self.is_admin(callback.from_user.id):
|
|
||||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
text = "⚙️ **Панель администратора**\n\n"
|
|
||||||
text += "Выберите раздел для управления:"
|
|
||||||
|
|
||||||
keyboard = self.keyboard_builder.get_admin_keyboard()
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
text,
|
|
||||||
reply_markup=keyboard,
|
|
||||||
parse_mode="Markdown"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_lottery_management(self, callback: CallbackQuery):
|
|
||||||
"""Обработать управление розыгрышами"""
|
|
||||||
if not self.is_admin(callback.from_user.id):
|
|
||||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
text = "🎯 **Управление розыгрышами**\n\n"
|
|
||||||
text += "Выберите действие:"
|
|
||||||
|
|
||||||
keyboard = self.keyboard_builder.get_lottery_management_keyboard()
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
text,
|
|
||||||
reply_markup=keyboard,
|
|
||||||
parse_mode="Markdown"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_conduct_lottery_admin(self, callback: CallbackQuery):
|
|
||||||
"""Обработать выбор розыгрыша для проведения"""
|
|
||||||
if not self.is_admin(callback.from_user.id):
|
|
||||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Получаем активные розыгрыши
|
|
||||||
lotteries = await self.lottery_service.get_active_lotteries()
|
|
||||||
|
|
||||||
if not lotteries:
|
|
||||||
await callback.answer("❌ Нет активных розыгрышей", show_alert=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
text = "🎯 **Выберите розыгрыш для проведения:**\n\n"
|
|
||||||
|
|
||||||
for lottery in lotteries:
|
|
||||||
participants_count = await self.participation_repo.get_count_by_lottery(lottery.id)
|
|
||||||
text += f"🎲 {lottery.title} ({participants_count} участников)\n"
|
|
||||||
|
|
||||||
keyboard = self.keyboard_builder.get_conduct_lottery_keyboard(lotteries)
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
text,
|
|
||||||
reply_markup=keyboard,
|
|
||||||
parse_mode="Markdown"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_active_lotteries(self, callback: CallbackQuery):
|
async def handle_active_lotteries(self, callback: CallbackQuery):
|
||||||
"""Показать активные розыгрыши"""
|
"""Показать активные розыгрыши"""
|
||||||
lotteries = await self.lottery_service.get_active_lotteries()
|
lotteries = await self.lottery_repo.get_active()
|
||||||
|
|
||||||
if not lotteries:
|
if not lotteries:
|
||||||
await callback.answer("❌ Нет активных розыгрышей", show_alert=True)
|
await callback.answer("❌ Нет активных розыгрышей", show_alert=True)
|
||||||
@@ -132,46 +74,21 @@ class BotController(IBotController):
|
|||||||
lottery_info = self.message_formatter.format_lottery_info(lottery, participants_count)
|
lottery_info = self.message_formatter.format_lottery_info(lottery, participants_count)
|
||||||
text += lottery_info + "\n" + "="*30 + "\n\n"
|
text += lottery_info + "\n" + "="*30 + "\n\n"
|
||||||
|
|
||||||
keyboard = self.keyboard_builder.get_main_keyboard(self.is_admin(callback.from_user.id))
|
# Получаем информацию о регистрации пользователя
|
||||||
|
user = await self.user_service.get_or_create_user(
|
||||||
|
telegram_id=callback.from_user.id,
|
||||||
|
username=callback.from_user.username,
|
||||||
|
first_name=callback.from_user.first_name,
|
||||||
|
last_name=callback.from_user.last_name
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = self.keyboard_builder.get_main_keyboard(
|
||||||
|
is_admin=self.is_admin(callback.from_user.id),
|
||||||
|
is_registered=user.is_registered
|
||||||
|
)
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
text,
|
text,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="Markdown"
|
parse_mode="Markdown"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def handle_conduct_lottery(self, callback: CallbackQuery):
|
|
||||||
"""Провести конкретный розыгрыш"""
|
|
||||||
if not self.is_admin(callback.from_user.id):
|
|
||||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
lottery_id = int(callback.data.split("_")[1])
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
await callback.answer("❌ Неверный формат данных", show_alert=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Проводим розыгрыш
|
|
||||||
results = await self.lottery_service.conduct_draw(lottery_id)
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
await callback.answer("❌ Не удалось провести розыгрыш", show_alert=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Форматируем результаты
|
|
||||||
text = "🎉 **Розыгрыш завершен!**\n\n"
|
|
||||||
|
|
||||||
winners = [result['winner'] for result in results.values()]
|
|
||||||
winners_text = self.message_formatter.format_winners_list(winners)
|
|
||||||
text += winners_text
|
|
||||||
|
|
||||||
keyboard = self.keyboard_builder.get_admin_keyboard()
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
text,
|
|
||||||
reply_markup=keyboard,
|
|
||||||
parse_mode="Markdown"
|
|
||||||
)
|
|
||||||
|
|
||||||
await callback.answer("✅ Розыгрыш успешно проведен!", show_alert=True)
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Сервисы для системы чата"""
|
"""Сервисы для системы чата"""
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, and_, or_, update, delete
|
from sqlalchemy import select, and_, or_, update, delete, text
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -185,6 +185,52 @@ class ChatMessageService:
|
|||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_message_by_telegram_id(
|
||||||
|
session: AsyncSession,
|
||||||
|
telegram_message_id: int,
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
) -> Optional[ChatMessage]:
|
||||||
|
"""
|
||||||
|
Получить сообщение по telegram_message_id
|
||||||
|
Ищет как по оригинальному telegram_message_id, так и в forwarded_message_ids
|
||||||
|
"""
|
||||||
|
# Сначала ищем по оригинальному telegram_message_id
|
||||||
|
query = select(ChatMessage).where(
|
||||||
|
ChatMessage.telegram_message_id == telegram_message_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
query = query.where(ChatMessage.user_id == user_id)
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
message = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Если нашли - возвращаем
|
||||||
|
if message:
|
||||||
|
return message
|
||||||
|
|
||||||
|
# Если не нашли - ищем в forwarded_message_ids
|
||||||
|
# Загружаем все недавние сообщения и ищем в них
|
||||||
|
query = select(ChatMessage).where(
|
||||||
|
ChatMessage.forwarded_message_ids.isnot(None)
|
||||||
|
).order_by(ChatMessage.created_at.desc()).limit(100)
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
messages = result.scalars().all()
|
||||||
|
|
||||||
|
# Ищем сообщение, где telegram_message_id есть в forwarded_message_ids
|
||||||
|
for msg in messages:
|
||||||
|
if msg.forwarded_message_ids:
|
||||||
|
for user_tid, fwd_msg_id in msg.forwarded_message_ids.items():
|
||||||
|
if fwd_msg_id == telegram_message_id:
|
||||||
|
return msg
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_user_messages(
|
async def get_user_messages(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
|||||||
@@ -215,4 +215,30 @@ class ChatMessage(Base):
|
|||||||
moderator = relationship("User", foreign_keys=[deleted_by])
|
moderator = relationship("User", foreign_keys=[deleted_by])
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<ChatMessage(id={self.id}, user_id={self.user_id}, type={self.message_type})>"
|
return f"<ChatMessage(id={self.id}, user_id={self.user_id}, type={self.message_type})>"
|
||||||
|
|
||||||
|
|
||||||
|
class P2PMessage(Base):
|
||||||
|
"""P2P сообщения между пользователями"""
|
||||||
|
__tablename__ = "p2p_messages"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
sender_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
recipient_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
message_type = Column(String(20), nullable=False) # text, photo, video, etc.
|
||||||
|
text = Column(Text, nullable=True)
|
||||||
|
file_id = Column(String(255), nullable=True)
|
||||||
|
sender_message_id = Column(Integer, nullable=False) # ID сообщения у отправителя
|
||||||
|
recipient_message_id = Column(Integer, nullable=True) # ID сообщения у получателя
|
||||||
|
is_read = Column(Boolean, default=False, index=True)
|
||||||
|
read_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
reply_to_id = Column(Integer, ForeignKey("p2p_messages.id"), nullable=True) # Ответ на сообщение
|
||||||
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True)
|
||||||
|
|
||||||
|
# Связи
|
||||||
|
sender = relationship("User", foreign_keys=[sender_id], backref="sent_p2p_messages")
|
||||||
|
recipient = relationship("User", foreign_keys=[recipient_id], backref="received_p2p_messages")
|
||||||
|
reply_to = relationship("P2PMessage", remote_side=[id], backref="replies")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<P2PMessage(id={self.id}, from={self.sender_id}, to={self.recipient_id})>"
|
||||||
263
src/core/p2p_services.py
Normal file
263
src/core/p2p_services.py
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"""Сервисы для работы с P2P сообщениями"""
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, and_, or_, desc, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from .models import P2PMessage, User
|
||||||
|
|
||||||
|
|
||||||
|
class P2PMessageService:
|
||||||
|
"""Сервис для работы с P2P сообщениями"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def send_message(
|
||||||
|
session: AsyncSession,
|
||||||
|
sender_id: int,
|
||||||
|
recipient_id: int,
|
||||||
|
message_type: str,
|
||||||
|
sender_message_id: int,
|
||||||
|
recipient_message_id: Optional[int] = None,
|
||||||
|
text: Optional[str] = None,
|
||||||
|
file_id: Optional[str] = None,
|
||||||
|
reply_to_id: Optional[int] = None
|
||||||
|
) -> P2PMessage:
|
||||||
|
"""
|
||||||
|
Сохранить отправленное P2P сообщение
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
sender_id: ID отправителя
|
||||||
|
recipient_id: ID получателя
|
||||||
|
message_type: Тип сообщения (text, photo, etc.)
|
||||||
|
sender_message_id: ID сообщения у отправителя
|
||||||
|
recipient_message_id: ID сообщения у получателя
|
||||||
|
text: Текст сообщения
|
||||||
|
file_id: ID файла
|
||||||
|
reply_to_id: ID сообщения, на которое отвечают
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
P2PMessage
|
||||||
|
"""
|
||||||
|
message = P2PMessage(
|
||||||
|
sender_id=sender_id,
|
||||||
|
recipient_id=recipient_id,
|
||||||
|
message_type=message_type,
|
||||||
|
text=text,
|
||||||
|
file_id=file_id,
|
||||||
|
sender_message_id=sender_message_id,
|
||||||
|
recipient_message_id=recipient_message_id,
|
||||||
|
reply_to_id=reply_to_id,
|
||||||
|
created_at=datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(message)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(message)
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def mark_as_read(session: AsyncSession, message_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Отметить сообщение как прочитанное
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
message_id: ID сообщения
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если успешно
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(P2PMessage).where(P2PMessage.id == message_id)
|
||||||
|
)
|
||||||
|
message = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if message and not message.is_read:
|
||||||
|
message.is_read = True
|
||||||
|
message.read_at = datetime.now(timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_conversation(
|
||||||
|
session: AsyncSession,
|
||||||
|
user1_id: int,
|
||||||
|
user2_id: int,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0
|
||||||
|
) -> List[P2PMessage]:
|
||||||
|
"""
|
||||||
|
Получить переписку между двумя пользователями
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
user1_id: ID первого пользователя
|
||||||
|
user2_id: ID второго пользователя
|
||||||
|
limit: Максимальное количество сообщений
|
||||||
|
offset: Смещение для пагинации
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[P2PMessage]: Список сообщений (от новых к старым)
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(P2PMessage)
|
||||||
|
.options(selectinload(P2PMessage.sender), selectinload(P2PMessage.recipient))
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
and_(P2PMessage.sender_id == user1_id, P2PMessage.recipient_id == user2_id),
|
||||||
|
and_(P2PMessage.sender_id == user2_id, P2PMessage.recipient_id == user1_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(desc(P2PMessage.created_at))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_unread_count(session: AsyncSession, user_id: int) -> int:
|
||||||
|
"""
|
||||||
|
Получить количество непрочитанных сообщений пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
user_id: ID пользователя
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Количество непрочитанных сообщений
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(func.count(P2PMessage.id))
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
P2PMessage.recipient_id == user_id,
|
||||||
|
P2PMessage.is_read == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.scalar() or 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_recent_conversations(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
limit: int = 10
|
||||||
|
) -> List[Tuple[User, P2PMessage, int]]:
|
||||||
|
"""
|
||||||
|
Получить список последних диалогов пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
user_id: ID пользователя
|
||||||
|
limit: Максимальное количество диалогов
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[Tuple[User, P2PMessage, int]]: Список (собеседник, последнее_сообщение, непрочитанных)
|
||||||
|
"""
|
||||||
|
# Получаем все ID собеседников
|
||||||
|
result = await session.execute(
|
||||||
|
select(P2PMessage.sender_id, P2PMessage.recipient_id)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
P2PMessage.sender_id == user_id,
|
||||||
|
P2PMessage.recipient_id == user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Собираем уникальных собеседников
|
||||||
|
peers = set()
|
||||||
|
for sender_id, recipient_id in result.all():
|
||||||
|
peer_id = recipient_id if sender_id == user_id else sender_id
|
||||||
|
peers.add(peer_id)
|
||||||
|
|
||||||
|
# Для каждого собеседника получаем последнее сообщение и количество непрочитанных
|
||||||
|
conversations = []
|
||||||
|
|
||||||
|
for peer_id in peers:
|
||||||
|
# Последнее сообщение
|
||||||
|
last_msg_result = await session.execute(
|
||||||
|
select(P2PMessage)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
and_(P2PMessage.sender_id == user_id, P2PMessage.recipient_id == peer_id),
|
||||||
|
and_(P2PMessage.sender_id == peer_id, P2PMessage.recipient_id == user_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(desc(P2PMessage.created_at))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
last_message = last_msg_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not last_message:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Количество непрочитанных от этого собеседника
|
||||||
|
unread_result = await session.execute(
|
||||||
|
select(func.count(P2PMessage.id))
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
P2PMessage.sender_id == peer_id,
|
||||||
|
P2PMessage.recipient_id == user_id,
|
||||||
|
P2PMessage.is_read == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
unread_count = unread_result.scalar() or 0
|
||||||
|
|
||||||
|
# Получаем пользователя-собеседника
|
||||||
|
peer_result = await session.execute(
|
||||||
|
select(User).where(User.id == peer_id)
|
||||||
|
)
|
||||||
|
peer = peer_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if peer:
|
||||||
|
conversations.append((peer, last_message, unread_count))
|
||||||
|
|
||||||
|
# Сортируем по времени последнего сообщения
|
||||||
|
conversations.sort(key=lambda x: x[1].created_at, reverse=True)
|
||||||
|
|
||||||
|
return conversations[:limit]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def find_original_message(
|
||||||
|
session: AsyncSession,
|
||||||
|
telegram_message_id: int,
|
||||||
|
user_id: int
|
||||||
|
) -> Optional[P2PMessage]:
|
||||||
|
"""
|
||||||
|
Найти оригинальное P2P сообщение по telegram_message_id
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
telegram_message_id: ID сообщения в Telegram
|
||||||
|
user_id: ID пользователя (для проверки прав)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[P2PMessage]: Найденное сообщение или None
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(P2PMessage)
|
||||||
|
.options(selectinload(P2PMessage.sender), selectinload(P2PMessage.recipient))
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
and_(
|
||||||
|
P2PMessage.sender_message_id == telegram_message_id,
|
||||||
|
P2PMessage.sender_id == user_id
|
||||||
|
),
|
||||||
|
and_(
|
||||||
|
P2PMessage.recipient_message_id == telegram_message_id,
|
||||||
|
P2PMessage.recipient_id == user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.scalar_one_or_none()
|
||||||
@@ -147,6 +147,23 @@ class UserService:
|
|||||||
formatted_number = format_account_number(account_number)
|
formatted_number = format_account_number(account_number)
|
||||||
if not formatted_number:
|
if not formatted_number:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_user_by_club_card(session: AsyncSession, club_card_number: str) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Получить пользователя по номеру клубной карты
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
club_card_number: Номер клубной карты (4 цифры)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User или None если не найден
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(User).where(User.club_card_number == club_card_number)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(User).where(User.account_number == formatted_number)
|
select(User).where(User.account_number == formatted_number)
|
||||||
@@ -539,6 +556,9 @@ class ParticipationService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def add_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]:
|
async def add_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]:
|
||||||
"""Массовое добавление участников по номерам счетов"""
|
"""Массовое добавление участников по номерам счетов"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
results = {
|
results = {
|
||||||
"added": 0,
|
"added": 0,
|
||||||
"skipped": 0,
|
"skipped": 0,
|
||||||
@@ -547,35 +567,97 @@ class ParticipationService:
|
|||||||
"invalid_accounts": []
|
"invalid_accounts": []
|
||||||
}
|
}
|
||||||
|
|
||||||
for account_number in account_numbers:
|
for account_input in account_numbers:
|
||||||
account_number = account_number.strip()
|
account_input = account_input.strip()
|
||||||
if not account_number:
|
if not account_input:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
logger.info(f"DEBUG: Processing account_input={account_input!r}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Валидируем и форматируем номер
|
# Разделяем по пробелу: левая часть - номер карты, правая - номер счета
|
||||||
formatted_account = format_account_number(account_number)
|
parts = account_input.split()
|
||||||
if not formatted_account:
|
logger.info(f"DEBUG: After split: parts={parts}, len={len(parts)}")
|
||||||
results["invalid_accounts"].append(account_number)
|
|
||||||
results["errors"].append(f"Неверный формат: {account_number}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Ищем пользователя по номеру счёта
|
if len(parts) == 2:
|
||||||
user = await UserService.get_user_by_account(session, formatted_account)
|
card_number = parts[0] # Номер клубной карты
|
||||||
if not user:
|
account_number = parts[1] # Номер счета
|
||||||
results["errors"].append(f"Пользователь с счётом {formatted_account} не найден")
|
logger.info(f"DEBUG: 2 parts - card={card_number!r}, account={account_number!r}")
|
||||||
continue
|
elif len(parts) == 1:
|
||||||
|
# Если нет пробела, считаем что это просто номер счета
|
||||||
# Пробуем добавить в розыгрыш
|
card_number = None
|
||||||
if await ParticipationService.add_participant(session, lottery_id, user.id):
|
account_number = parts[0]
|
||||||
results["added"] += 1
|
logger.info(f"DEBUG: 1 part - account={account_number!r}")
|
||||||
results["details"].append(f"Добавлен: {user.first_name} ({formatted_account})")
|
|
||||||
else:
|
else:
|
||||||
|
logger.info(f"DEBUG: Invalid parts count={len(parts)}")
|
||||||
|
results["invalid_accounts"].append(account_input)
|
||||||
|
results["errors"].append(f"Неверный формат: {account_input}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Валидируем и форматируем номер счета
|
||||||
|
logger.info(f"DEBUG: Before format_account_number: {account_number!r}")
|
||||||
|
formatted_account = format_account_number(account_number)
|
||||||
|
logger.info(f"DEBUG: After format_account_number: {formatted_account!r}")
|
||||||
|
|
||||||
|
if not formatted_account:
|
||||||
|
card_info = f" (карта: {card_number})" if card_number else ""
|
||||||
|
results["invalid_accounts"].append(account_input)
|
||||||
|
results["errors"].append(f"Неверный формат счета: {account_number}{card_info}")
|
||||||
|
logger.error(f"DEBUG: Format failed for {account_number!r}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ищем владельца счёта через таблицу Account
|
||||||
|
from ..core.registration_services import AccountService
|
||||||
|
user = await AccountService.get_account_owner(session, formatted_account)
|
||||||
|
if not user:
|
||||||
|
card_info = f" (карта: {card_number})" if card_number else ""
|
||||||
|
results["errors"].append(f"Пользователь с счётом {formatted_account}{card_info} не найден")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Получаем запись Account для этого счета
|
||||||
|
account_record = await session.execute(
|
||||||
|
select(Account).where(Account.account_number == formatted_account)
|
||||||
|
)
|
||||||
|
account_record = account_record.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not account_record:
|
||||||
|
card_info = f" (карта: {card_number})" if card_number else ""
|
||||||
|
results["errors"].append(f"Запись счета {formatted_account}{card_info} не найдена в базе")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Проверяем, не участвует ли уже этот счет
|
||||||
|
existing = await session.execute(
|
||||||
|
select(Participation).where(
|
||||||
|
Participation.lottery_id == lottery_id,
|
||||||
|
Participation.account_number == formatted_account
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
results["skipped"] += 1
|
results["skipped"] += 1
|
||||||
results["details"].append(f"Уже участвует: {user.first_name} ({formatted_account})")
|
detail = f"{user.first_name} ({formatted_account})"
|
||||||
|
if card_number:
|
||||||
|
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
|
||||||
|
results["details"].append(f"Уже участвует: {detail}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Добавляем участие по счету
|
||||||
|
participation = Participation(
|
||||||
|
lottery_id=lottery_id,
|
||||||
|
user_id=user.id,
|
||||||
|
account_id=account_record.id,
|
||||||
|
account_number=formatted_account
|
||||||
|
)
|
||||||
|
session.add(participation)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
results["added"] += 1
|
||||||
|
detail = f"{user.first_name} ({formatted_account})"
|
||||||
|
if card_number:
|
||||||
|
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
|
||||||
|
results["details"].append(detail)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results["errors"].append(f"Ошибка с {account_number}: {str(e)}")
|
results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -590,36 +672,70 @@ class ParticipationService:
|
|||||||
"invalid_accounts": []
|
"invalid_accounts": []
|
||||||
}
|
}
|
||||||
|
|
||||||
for account_number in account_numbers:
|
for account_input in account_numbers:
|
||||||
account_number = account_number.strip()
|
account_input = account_input.strip()
|
||||||
if not account_number:
|
if not account_input:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Валидируем и форматируем номер
|
# Разделяем по пробелу: левая часть - номер карты, правая - номер счета
|
||||||
|
parts = account_input.split()
|
||||||
|
if len(parts) == 2:
|
||||||
|
card_number = parts[0] # Номер клубной карты
|
||||||
|
account_number = parts[1] # Номер счета
|
||||||
|
elif len(parts) == 1:
|
||||||
|
# Если нет пробела, считаем что это просто номер счета
|
||||||
|
card_number = None
|
||||||
|
account_number = parts[0]
|
||||||
|
else:
|
||||||
|
results["invalid_accounts"].append(account_input)
|
||||||
|
results["errors"].append(f"Неверный формат: {account_input}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Валидируем и форматируем номер счета
|
||||||
formatted_account = format_account_number(account_number)
|
formatted_account = format_account_number(account_number)
|
||||||
if not formatted_account:
|
if not formatted_account:
|
||||||
results["invalid_accounts"].append(account_number)
|
card_info = f" (карта: {card_number})" if card_number else ""
|
||||||
results["errors"].append(f"Неверный формат: {account_number}")
|
results["invalid_accounts"].append(account_input)
|
||||||
|
results["errors"].append(f"Неверный формат счета: {account_number}{card_info}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Ищем пользователя по номеру счёта
|
# Ищем владельца счёта через таблицу Account
|
||||||
user = await UserService.get_user_by_account(session, formatted_account)
|
from ..core.registration_services import AccountService
|
||||||
|
user = await AccountService.get_account_owner(session, formatted_account)
|
||||||
if not user:
|
if not user:
|
||||||
|
card_info = f" (карта: {card_number})" if card_number else ""
|
||||||
results["not_found"] += 1
|
results["not_found"] += 1
|
||||||
results["details"].append(f"Не найден: {formatted_account}")
|
results["details"].append(f"Не найден: {formatted_account}{card_info}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Пробуем удалить из розыгрыша
|
# Ищем участие по номеру счета (не по user_id!)
|
||||||
if await ParticipationService.remove_participant(session, lottery_id, user.id):
|
participation = await session.execute(
|
||||||
|
select(Participation).where(
|
||||||
|
Participation.lottery_id == lottery_id,
|
||||||
|
Participation.account_number == formatted_account
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participation = participation.scalar_one_or_none()
|
||||||
|
|
||||||
|
if participation:
|
||||||
|
await session.delete(participation)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
results["removed"] += 1
|
results["removed"] += 1
|
||||||
results["details"].append(f"Удалён: {user.first_name} ({formatted_account})")
|
detail = f"{user.first_name} ({formatted_account})"
|
||||||
|
if card_number:
|
||||||
|
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
|
||||||
|
results["details"].append(detail)
|
||||||
else:
|
else:
|
||||||
results["not_found"] += 1
|
results["not_found"] += 1
|
||||||
results["details"].append(f"Не участвовал: {user.first_name} ({formatted_account})")
|
detail = f"{user.first_name} ({formatted_account})"
|
||||||
|
if card_number:
|
||||||
|
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
|
||||||
|
results["details"].append(f"Не участвовал: {detail}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results["errors"].append(f"Ошибка с {account_number}: {str(e)}")
|
results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ async def detect_account_input(message: Message, state: FSMContext):
|
|||||||
"""
|
"""
|
||||||
Обнаружение ввода счетов в сообщении
|
Обнаружение ввода счетов в сообщении
|
||||||
Активируется только для администраторов
|
Активируется только для администраторов
|
||||||
|
Извлекает номер клубной карты и определяет владельца
|
||||||
"""
|
"""
|
||||||
if not is_admin(message.from_user.id):
|
if not is_admin(message.from_user.id):
|
||||||
return
|
return
|
||||||
@@ -52,16 +53,75 @@ async def detect_account_input(message: Message, state: FSMContext):
|
|||||||
if not accounts:
|
if not accounts:
|
||||||
return # Счета не обнаружены, пропускаем
|
return # Счета не обнаружены, пропускаем
|
||||||
|
|
||||||
# Сохраняем счета в состоянии
|
# Извлекаем номера клубных карт и определяем владельцев
|
||||||
await state.update_data(detected_accounts=accounts)
|
from ..core.services import UserService
|
||||||
|
from ..core.registration_services import AccountService
|
||||||
|
|
||||||
# Формируем сообщение
|
async with async_session_maker() as session:
|
||||||
accounts_text = "\n".join([f"• {acc}" for acc in accounts])
|
accounts_with_owners = []
|
||||||
|
|
||||||
|
for account in accounts:
|
||||||
|
# Парсим строку счета: может быть "КАРТА СЧЕТ" или просто "СЧЕТ"
|
||||||
|
parts = account.split()
|
||||||
|
|
||||||
|
club_card = None
|
||||||
|
account_number = None
|
||||||
|
|
||||||
|
if len(parts) == 2:
|
||||||
|
# Формат: "КАРТА СЧЕТ" (например "2521 21-04-80-64-68-25-68")
|
||||||
|
club_card = parts[0]
|
||||||
|
account_number = parts[1]
|
||||||
|
elif len(parts) == 1:
|
||||||
|
# Формат: только "СЧЕТ" (например "21-04-80-64-68-25-68")
|
||||||
|
account_number = parts[0]
|
||||||
|
|
||||||
|
# Если есть номер клубной карты, ищем владельца
|
||||||
|
user = None
|
||||||
|
owner_info = None
|
||||||
|
if club_card:
|
||||||
|
user = await UserService.get_user_by_club_card(session, club_card)
|
||||||
|
if user:
|
||||||
|
owner_info = f"@{user.username}" if user.username else user.first_name
|
||||||
|
|
||||||
|
accounts_with_owners.append({
|
||||||
|
'account': account,
|
||||||
|
'club_card': club_card,
|
||||||
|
'owner': owner_info,
|
||||||
|
'user_id': user.id if user else None
|
||||||
|
})
|
||||||
|
|
||||||
|
# Сохраняем счета в состоянии
|
||||||
|
await state.update_data(
|
||||||
|
detected_accounts=accounts,
|
||||||
|
accounts_with_owners=accounts_with_owners
|
||||||
|
)
|
||||||
|
|
||||||
|
# Формируем сообщение с владельцами
|
||||||
|
accounts_text_parts = []
|
||||||
|
for item in accounts_with_owners:
|
||||||
|
account = item['account']
|
||||||
|
club_card = item['club_card']
|
||||||
|
owner = item['owner']
|
||||||
|
|
||||||
|
if owner:
|
||||||
|
line = f"• {account} → {owner} (карта: {club_card})"
|
||||||
|
elif club_card:
|
||||||
|
line = f"• {account} (карта: {club_card}, владелец не найден)"
|
||||||
|
else:
|
||||||
|
line = f"• {account} (неверный формат)"
|
||||||
|
|
||||||
|
accounts_text_parts.append(line)
|
||||||
|
|
||||||
|
accounts_text = "\n".join(accounts_text_parts)
|
||||||
count = len(accounts)
|
count = len(accounts)
|
||||||
|
|
||||||
|
# Подсчёт найденных владельцев
|
||||||
|
owners_found = sum(1 for item in accounts_with_owners if item['owner'])
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
f"🔍 <b>Обнаружен ввод счет{'а' if count == 1 else 'ов'}</b>\n\n"
|
f"🔍 <b>Обнаружен ввод счет{'а' if count == 1 else 'ов'}</b>\n\n"
|
||||||
f"Найдено: <b>{count}</b>\n\n"
|
f"Найдено: <b>{count}</b>\n"
|
||||||
|
f"Владельцев определено: <b>{owners_found}</b>\n\n"
|
||||||
f"{accounts_text}\n\n"
|
f"{accounts_text}\n\n"
|
||||||
f"Выберите действие:"
|
f"Выберите действие:"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,17 +18,34 @@ class AccountParticipationService:
|
|||||||
account_number: str
|
account_number: str
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Добавить счет в розыгрыш
|
Добавить счет в розыгрыш.
|
||||||
|
Поддерживает форматы: "КАРТА СЧЕТ" или просто "СЧЕТ"
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict с ключами: success, message, account_number
|
Dict с ключами: success, message, account_number
|
||||||
"""
|
"""
|
||||||
# Валидируем и форматируем
|
# Разделяем по пробелу если есть номер карты
|
||||||
formatted_account = format_account_number(account_number)
|
parts = account_number.split()
|
||||||
if not formatted_account:
|
if len(parts) == 2:
|
||||||
|
card_number = parts[0]
|
||||||
|
account_to_format = parts[1]
|
||||||
|
elif len(parts) == 1:
|
||||||
|
card_number = None
|
||||||
|
account_to_format = parts[0]
|
||||||
|
else:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": f"Неверный формат счета: {account_number}",
|
"message": f"Неверный формат: {account_number}",
|
||||||
|
"account_number": account_number
|
||||||
|
}
|
||||||
|
|
||||||
|
# Валидируем и форматируем только часть счета
|
||||||
|
formatted_account = format_account_number(account_to_format)
|
||||||
|
if not formatted_account:
|
||||||
|
card_info = f" (карта: {card_number})" if card_number else ""
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Неверный формат счета: {account_to_format}{card_info}",
|
||||||
"account_number": account_number
|
"account_number": account_number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,24 +66,37 @@ class AccountParticipationService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
|
card_info = f" (карта: {card_number})" if card_number else ""
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": f"Счет {formatted_account} уже участвует в розыгрыше",
|
"message": f"Счет {formatted_account}{card_info} уже участвует в розыгрыше",
|
||||||
"account_number": formatted_account
|
"account_number": formatted_account
|
||||||
}
|
}
|
||||||
|
|
||||||
# Добавляем участие
|
# Получаем запись Account и владельца
|
||||||
|
from ..core.registration_services import AccountService
|
||||||
|
from ..core.models import Account
|
||||||
|
|
||||||
|
user = await AccountService.get_account_owner(session, formatted_account)
|
||||||
|
account_record = await session.execute(
|
||||||
|
select(Account).where(Account.account_number == formatted_account)
|
||||||
|
)
|
||||||
|
account_record = account_record.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Добавляем участие с полными данными
|
||||||
participation = Participation(
|
participation = Participation(
|
||||||
lottery_id=lottery_id,
|
lottery_id=lottery_id,
|
||||||
account_number=formatted_account,
|
account_number=formatted_account,
|
||||||
user_id=None # Без привязки к пользователю
|
user_id=user.id if user else None,
|
||||||
|
account_id=account_record.id if account_record else None
|
||||||
)
|
)
|
||||||
session.add(participation)
|
session.add(participation)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
card_info = f" (карта: {card_number})" if card_number else ""
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Счет {formatted_account} добавлен в розыгрыш",
|
"message": f"Счет {formatted_account}{card_info} добавлен в розыгрыш",
|
||||||
"account_number": formatted_account
|
"account_number": formatted_account
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,30 +229,52 @@ class AccountParticipationService:
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
lottery_id: int,
|
lottery_id: int,
|
||||||
account_number: str,
|
account_number: str,
|
||||||
place: int,
|
place: int = 1,
|
||||||
prize: Optional[str] = None
|
prize: str = ""
|
||||||
) -> Dict[str, Any]:
|
):
|
||||||
"""
|
"""
|
||||||
Установить счет как победителя на указанное место
|
Устанавливает счет как победителя в розыгрыше.
|
||||||
|
Поддерживает формат: "КАРТА СЧЕТ" или просто "СЧЕТ"
|
||||||
"""
|
"""
|
||||||
formatted_account = format_account_number(account_number)
|
# Разделяем номер карты и счета, если они указаны вместе
|
||||||
|
card_number = None
|
||||||
|
parts = account_number.split()
|
||||||
|
|
||||||
|
if len(parts) == 2:
|
||||||
|
# Формат: "КАРТА СЧЕТ"
|
||||||
|
card_number = parts[0]
|
||||||
|
account_to_format = parts[1]
|
||||||
|
elif len(parts) == 1:
|
||||||
|
# Формат: только "СЧЕТ"
|
||||||
|
account_to_format = parts[0]
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"❌ Неверный формат: {account_number}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Форматируем номер счета
|
||||||
|
formatted_account = format_account_number(account_to_format)
|
||||||
if not formatted_account:
|
if not formatted_account:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": f"Неверный формат счета: {account_number}"
|
"message": f"❌ Неверный формат счета: {account_number}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Проверяем, участвует ли счет в розыгрыше
|
# Проверяем, что счет участвует в розыгрыше
|
||||||
participation = await session.execute(
|
participation = await session.execute(
|
||||||
select(Participation).where(
|
select(Participation).where(
|
||||||
Participation.lottery_id == lottery_id,
|
Participation.lottery_id == lottery_id,
|
||||||
Participation.account_number == formatted_account
|
Participation.account_number == formatted_account
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not participation.scalar_one_or_none():
|
participation = participation.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not participation:
|
||||||
|
card_info = f" (карта: {card_number})" if card_number else ""
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": f"Счет {formatted_account} не участвует в розыгрыше"
|
"message": f"❌ Счет {formatted_account}{card_info} не участвует в розыгрыше"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Проверяем, не занято ли уже это место
|
# Проверяем, не занято ли уже это место
|
||||||
@@ -255,9 +307,10 @@ class AccountParticipationService:
|
|||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
card_info = f" (карта: {card_number})" if card_number else ""
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Счет {formatted_account} установлен победителем на место {place}",
|
"message": f"✅ Счет {formatted_account}{card_info} установлен победителем на место {place}",
|
||||||
"account_number": formatted_account,
|
"account_number": formatted_account,
|
||||||
"place": place
|
"place": place
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Расширенная админ-панель для управления розыгрышами
|
Расширенная админ-панель для управления розыгрышами
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import (
|
from aiogram.types import (
|
||||||
CallbackQuery, Message, InlineKeyboardButton, InlineKeyboardMarkup
|
CallbackQuery, Message, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
@@ -17,6 +18,8 @@ from ..core.services import UserService, LotteryService, ParticipationService
|
|||||||
from ..core.config import ADMIN_IDS
|
from ..core.config import ADMIN_IDS
|
||||||
from ..core.models import User, Lottery, Participation, Account
|
from ..core.models import User, Lottery, Participation, Account
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Состояния для админки
|
# Состояния для админки
|
||||||
class AdminStates(StatesGroup):
|
class AdminStates(StatesGroup):
|
||||||
@@ -406,7 +409,14 @@ async def show_lottery_detail(callback: CallbackQuery):
|
|||||||
text += f"🏆 Результаты:\n"
|
text += f"🏆 Результаты:\n"
|
||||||
for winner in winners:
|
for winner in winners:
|
||||||
manual_mark = " 👑" if winner.is_manual else ""
|
manual_mark = " 👑" if winner.is_manual else ""
|
||||||
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
|
||||||
|
# Безопасная обработка победителя - может быть без user_id
|
||||||
|
if winner.user:
|
||||||
|
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||||
|
else:
|
||||||
|
# Победитель по номеру счета без связанного пользователя
|
||||||
|
username = f"Счет: {winner.account_number}"
|
||||||
|
|
||||||
text += f"{winner.place}. {username}{manual_mark}\n"
|
text += f"{winner.place}. {username}{manual_mark}\n"
|
||||||
|
|
||||||
buttons = []
|
buttons = []
|
||||||
@@ -1344,13 +1354,9 @@ async def process_bulk_add_accounts(message: Message, state: FSMContext):
|
|||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
lottery_id = data['bulk_add_accounts_lottery_id']
|
lottery_id = data['bulk_add_accounts_lottery_id']
|
||||||
|
|
||||||
# Парсим входные данные - поддерживаем и запятые, и переносы строк
|
# Используем функцию парсинга из account_utils для корректной обработки формата "КАРТА СЧЕТ"
|
||||||
account_inputs = []
|
from ..utils.account_utils import parse_accounts_from_message
|
||||||
for line in message.text.split('\n'):
|
account_inputs = parse_accounts_from_message(message.text)
|
||||||
for account in line.split(','):
|
|
||||||
account = account.strip()
|
|
||||||
if account:
|
|
||||||
account_inputs.append(account)
|
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
# Массовое добавление по номерам счетов
|
# Массовое добавление по номерам счетов
|
||||||
@@ -1473,13 +1479,9 @@ async def process_bulk_remove_accounts(message: Message, state: FSMContext):
|
|||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
lottery_id = data['bulk_remove_accounts_lottery_id']
|
lottery_id = data['bulk_remove_accounts_lottery_id']
|
||||||
|
|
||||||
# Парсим входные данные - поддерживаем и запятые, и переносы строк
|
# Используем функцию парсинга из account_utils для корректной обработки формата "КАРТА СЧЕТ"
|
||||||
account_inputs = []
|
from ..utils.account_utils import parse_accounts_from_message
|
||||||
for line in message.text.split('\n'):
|
account_inputs = parse_accounts_from_message(message.text)
|
||||||
for account in line.split(','):
|
|
||||||
account = account.strip()
|
|
||||||
if account:
|
|
||||||
account_inputs.append(account)
|
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
# Массовое удаление по номерам счетов
|
# Массовое удаление по номерам счетов
|
||||||
@@ -2591,8 +2593,8 @@ async def choose_lottery_for_draw(callback: CallbackQuery):
|
|||||||
|
|
||||||
|
|
||||||
@admin_router.callback_query(F.data.startswith("admin_conduct_"))
|
@admin_router.callback_query(F.data.startswith("admin_conduct_"))
|
||||||
async def conduct_lottery_draw(callback: CallbackQuery):
|
async def conduct_lottery_draw_confirm(callback: CallbackQuery):
|
||||||
"""Проведение розыгрыша"""
|
"""Запрос подтверждения проведения розыгрыша"""
|
||||||
if not is_admin(callback.from_user.id):
|
if not is_admin(callback.from_user.id):
|
||||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||||
return
|
return
|
||||||
@@ -2616,10 +2618,73 @@ async def conduct_lottery_draw(callback: CallbackQuery):
|
|||||||
await callback.answer("Нет участников для розыгрыша", show_alert=True)
|
await callback.answer("Нет участников для розыгрыша", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Подсчёт призов
|
||||||
|
prizes_count = len(lottery.prizes) if lottery.prizes else 0
|
||||||
|
|
||||||
|
# Формируем сообщение с подтверждением
|
||||||
|
text = f"⚠️ <b>Подтверждение проведения розыгрыша</b>\n\n"
|
||||||
|
text += f"🎲 <b>Розыгрыш:</b> {lottery.title}\n"
|
||||||
|
text += f"👥 <b>Участников:</b> {participants_count}\n"
|
||||||
|
text += f"🏆 <b>Призов:</b> {prizes_count}\n\n"
|
||||||
|
|
||||||
|
if lottery.prizes:
|
||||||
|
text += "<b>Призы:</b>\n"
|
||||||
|
for i, prize in enumerate(lottery.prizes, 1):
|
||||||
|
text += f"{i}. {prize}\n"
|
||||||
|
text += "\n"
|
||||||
|
|
||||||
|
text += "❗️ <b>Внимание:</b> После проведения розыгрыша результаты нельзя будет изменить!\n\n"
|
||||||
|
text += "Продолжить?"
|
||||||
|
|
||||||
|
buttons = [
|
||||||
|
[InlineKeyboardButton(text="✅ Да, провести розыгрыш", callback_data=f"admin_conduct_confirmed_{lottery_id}")],
|
||||||
|
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_lottery_{lottery_id}")]
|
||||||
|
]
|
||||||
|
|
||||||
|
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.callback_query(F.data.startswith("admin_conduct_confirmed_"))
|
||||||
|
async def conduct_lottery_draw(callback: CallbackQuery):
|
||||||
|
"""Проведение розыгрыша после подтверждения"""
|
||||||
|
if not is_admin(callback.from_user.id):
|
||||||
|
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
lottery_id = int(callback.data.split("_")[-1])
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||||
|
|
||||||
|
if not lottery:
|
||||||
|
await callback.answer("Розыгрыш не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if lottery.is_completed:
|
||||||
|
await callback.answer("Розыгрыш уже завершён", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
participants_count = await ParticipationService.get_participants_count(session, lottery_id)
|
||||||
|
|
||||||
|
if participants_count == 0:
|
||||||
|
await callback.answer("Нет участников для розыгрыша", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Показываем индикатор загрузки
|
||||||
|
await callback.answer("⏳ Проводится розыгрыш...", show_alert=True)
|
||||||
|
|
||||||
# Проводим розыгрыш через сервис
|
# Проводим розыгрыш через сервис
|
||||||
winners_dict = await LotteryService.conduct_draw(session, lottery_id)
|
winners_dict = await LotteryService.conduct_draw(session, lottery_id)
|
||||||
|
|
||||||
if winners_dict:
|
if winners_dict:
|
||||||
|
# Отправляем уведомления победителям
|
||||||
|
from ..utils.notifications import notify_winners_async
|
||||||
|
try:
|
||||||
|
await notify_winners_async(callback.bot, session, lottery_id)
|
||||||
|
logger.info(f"Уведомления отправлены для розыгрыша {lottery_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке уведомлений: {e}")
|
||||||
|
|
||||||
# Получаем победителей из базы
|
# Получаем победителей из базы
|
||||||
winners = await LotteryService.get_winners(session, lottery_id)
|
winners = await LotteryService.get_winners(session, lottery_id)
|
||||||
text = f"🎉 Розыгрыш '{lottery.title}' завершён!\n\n"
|
text = f"🎉 Розыгрыш '{lottery.title}' завершён!\n\n"
|
||||||
@@ -2633,6 +2698,8 @@ async def conduct_lottery_draw(callback: CallbackQuery):
|
|||||||
else:
|
else:
|
||||||
text += f"{winner.place} место: ID {winner.user_id}\n"
|
text += f"{winner.place} место: ID {winner.user_id}\n"
|
||||||
|
|
||||||
|
text += "\n✅ Уведомления отправлены победителям"
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
text,
|
text,
|
||||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
|
|||||||
@router.message(F.text)
|
@router.message(F.text)
|
||||||
async def handle_text_message(message: Message):
|
async def handle_text_message(message: Message):
|
||||||
"""Обработчик текстовых сообщений"""
|
"""Обработчик текстовых сообщений"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info(f"[CHAT] handle_text_message вызван: user={message.from_user.id}, text={message.text[:50] if message.text else 'None'}")
|
||||||
|
|
||||||
# Проверяем является ли это командой
|
# Проверяем является ли это командой
|
||||||
if message.text and message.text.startswith('/'):
|
if message.text and message.text.startswith('/'):
|
||||||
# Список команд, которые НЕ нужно пересылать
|
# Список команд, которые НЕ нужно пересылать
|
||||||
@@ -171,11 +175,13 @@ async def handle_text_message(message: Message):
|
|||||||
forwarded_ids=forwarded_ids
|
forwarded_ids=forwarded_ids
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(
|
# Показываем статистику доставки только админам
|
||||||
f"✅ Сообщение разослано!\n"
|
if is_admin(message.from_user.id):
|
||||||
f"📤 Доставлено: {success}\n"
|
await message.answer(
|
||||||
f"❌ Не доставлено: {fail}"
|
f"✅ Сообщение разослано!\n"
|
||||||
)
|
f"📤 Доставлено: {success}\n"
|
||||||
|
f"❌ Не доставлено: {fail}"
|
||||||
|
)
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
# Режим пересылки в канал
|
# Режим пересылки в канал
|
||||||
@@ -237,7 +243,9 @@ async def handle_photo_message(message: Message):
|
|||||||
forwarded_ids=forwarded_ids
|
forwarded_ids=forwarded_ids
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(f"✅ Фото разослано: {success} получателей")
|
# Показываем статистику только админам
|
||||||
|
if is_admin(message.from_user.id):
|
||||||
|
await message.answer(f"✅ Фото разослано: {success} получателей")
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
if settings.forward_chat_id:
|
if settings.forward_chat_id:
|
||||||
@@ -289,7 +297,9 @@ async def handle_video_message(message: Message):
|
|||||||
forwarded_ids=forwarded_ids
|
forwarded_ids=forwarded_ids
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(f"✅ Видео разослано: {success} получателей")
|
# Показываем статистику только админам
|
||||||
|
if is_admin(message.from_user.id):
|
||||||
|
await message.answer(f"✅ Видео разослано: {success} получателей")
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
if settings.forward_chat_id:
|
if settings.forward_chat_id:
|
||||||
@@ -341,7 +351,9 @@ async def handle_document_message(message: Message):
|
|||||||
forwarded_ids=forwarded_ids
|
forwarded_ids=forwarded_ids
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(f"✅ Документ разослан: {success} получателей")
|
# Показываем статистику только админам
|
||||||
|
if is_admin(message.from_user.id):
|
||||||
|
await message.answer(f"✅ Документ разослан: {success} получателей")
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
if settings.forward_chat_id:
|
if settings.forward_chat_id:
|
||||||
@@ -393,7 +405,9 @@ async def handle_animation_message(message: Message):
|
|||||||
forwarded_ids=forwarded_ids
|
forwarded_ids=forwarded_ids
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(f"✅ Анимация разослана: {success} получателей")
|
# Показываем статистику только админам
|
||||||
|
if is_admin(message.from_user.id):
|
||||||
|
await message.answer(f"✅ Анимация разослана: {success} получателей")
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
if settings.forward_chat_id:
|
if settings.forward_chat_id:
|
||||||
@@ -444,7 +458,9 @@ async def handle_sticker_message(message: Message):
|
|||||||
forwarded_ids=forwarded_ids
|
forwarded_ids=forwarded_ids
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(f"✅ Стикер разослан: {success} получателей")
|
# Показываем статистику только админам
|
||||||
|
if is_admin(message.from_user.id):
|
||||||
|
await message.answer(f"✅ Стикер разослан: {success} получателей")
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
if settings.forward_chat_id:
|
if settings.forward_chat_id:
|
||||||
@@ -494,7 +510,9 @@ async def handle_voice_message(message: Message):
|
|||||||
forwarded_ids=forwarded_ids
|
forwarded_ids=forwarded_ids
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(f"✅ Голосовое сообщение разослано: {success} получателей")
|
# Показываем статистику только админам
|
||||||
|
if is_admin(message.from_user.id):
|
||||||
|
await message.answer(f"✅ Голосовое сообщение разослано: {success} получателей")
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
if settings.forward_chat_id:
|
if settings.forward_chat_id:
|
||||||
|
|||||||
178
src/handlers/message_management.py
Normal file
178
src/handlers/message_management.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
Хэндлеры для управления сообщениями администратором
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from aiogram import Router, F, Bot
|
||||||
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
from aiogram.filters import Command
|
||||||
|
|
||||||
|
from ..core.config import ADMIN_IDS
|
||||||
|
from ..core.database import async_session_maker
|
||||||
|
from ..core.chat_services import ChatMessageService
|
||||||
|
from ..core.services import UserService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
message_admin_router = Router(name="message_admin")
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(user_id: int) -> bool:
|
||||||
|
"""Проверка, является ли пользователь администратором"""
|
||||||
|
return user_id in ADMIN_IDS
|
||||||
|
|
||||||
|
|
||||||
|
@message_admin_router.message(Command("delete"))
|
||||||
|
async def delete_replied_message(message: Message):
|
||||||
|
"""
|
||||||
|
Удаление сообщения по команде /delete
|
||||||
|
Работает только если команда является ответом на сообщение бота
|
||||||
|
"""
|
||||||
|
if not is_admin(message.from_user.id):
|
||||||
|
await message.answer("❌ Недостаточно прав")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not message.reply_to_message:
|
||||||
|
await message.answer("⚠️ Ответьте на сообщение бота командой /delete чтобы удалить его")
|
||||||
|
return
|
||||||
|
|
||||||
|
if message.reply_to_message.from_user.id != message.bot.id:
|
||||||
|
await message.answer("⚠️ Можно удалять только сообщения бота")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Удаляем сообщение бота
|
||||||
|
await message.reply_to_message.delete()
|
||||||
|
# Удаляем команду
|
||||||
|
await message.delete()
|
||||||
|
logger.info(f"Администратор {message.from_user.id} удалил сообщение {message.reply_to_message.message_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||||
|
await message.answer(f"❌ Не удалось удалить сообщение: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@message_admin_router.callback_query(F.data == "delete_message")
|
||||||
|
async def delete_message_callback(callback: CallbackQuery):
|
||||||
|
"""
|
||||||
|
Удаление сообщения по нажатию кнопки
|
||||||
|
"""
|
||||||
|
if not is_admin(callback.from_user.id):
|
||||||
|
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.delete()
|
||||||
|
await callback.answer("✅ Сообщение удалено")
|
||||||
|
logger.info(f"Администратор {callback.from_user.id} удалил сообщение через кнопку")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при удалении сообщения через кнопку: {e}")
|
||||||
|
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Функция-фильтр для проверки триггерных слов
|
||||||
|
def is_delete_trigger(message: Message) -> bool:
|
||||||
|
"""Проверяет, является ли сообщение триггером для удаления"""
|
||||||
|
if not message.text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
text_lower = message.text.lower().strip()
|
||||||
|
triggers = ["удалить", "delete", "del", "🗑️", "🗑", "❌"]
|
||||||
|
return any(trigger in text_lower for trigger in triggers)
|
||||||
|
|
||||||
|
|
||||||
|
@message_admin_router.message(F.reply_to_message, is_delete_trigger)
|
||||||
|
async def quick_delete_replied_message(message: Message):
|
||||||
|
"""
|
||||||
|
Быстрое удаление сообщения по reply с триггерными словами или emoji
|
||||||
|
Работает для админов при ответе на любое сообщение
|
||||||
|
|
||||||
|
Триггеры:
|
||||||
|
- "удалить", "delete", "del"
|
||||||
|
- 🗑️ (мусорная корзина)
|
||||||
|
- ❌ (крестик)
|
||||||
|
|
||||||
|
Удаляет сообщение у всех получателей broadcast рассылки
|
||||||
|
"""
|
||||||
|
if not is_admin(message.from_user.id):
|
||||||
|
return # Не админ - пропускаем
|
||||||
|
|
||||||
|
try:
|
||||||
|
replied_msg = message.reply_to_message
|
||||||
|
deleted_count = 0
|
||||||
|
|
||||||
|
# Пытаемся найти сообщение в БД по telegram_message_id
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
# Получаем admin user для deleted_by
|
||||||
|
admin_user = await UserService.get_user_by_telegram_id(
|
||||||
|
session,
|
||||||
|
message.from_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not admin_user:
|
||||||
|
logger.error(f"Админ {message.from_user.id} не найден в БД")
|
||||||
|
await message.answer("❌ Ошибка: пользователь не найден")
|
||||||
|
return
|
||||||
|
|
||||||
|
chat_message = await ChatMessageService.get_message_by_telegram_id(
|
||||||
|
session,
|
||||||
|
telegram_message_id=replied_msg.message_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Если нашли broadcast сообщение - удаляем у всех получателей
|
||||||
|
if chat_message and chat_message.forwarded_message_ids:
|
||||||
|
bot = message.bot
|
||||||
|
|
||||||
|
for user_telegram_id, forwarded_msg_id in chat_message.forwarded_message_ids.items():
|
||||||
|
try:
|
||||||
|
await bot.delete_message(
|
||||||
|
chat_id=int(user_telegram_id),
|
||||||
|
message_id=forwarded_msg_id
|
||||||
|
)
|
||||||
|
deleted_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Не удалось удалить сообщение у {user_telegram_id}: {e}")
|
||||||
|
|
||||||
|
# Помечаем как удалённое в БД (используем admin_user.id, а не telegram_id)
|
||||||
|
await ChatMessageService.delete_message(
|
||||||
|
session,
|
||||||
|
message_id=chat_message.id,
|
||||||
|
deleted_by=admin_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Администратор {message.from_user.id} удалил broadcast сообщение "
|
||||||
|
f"{replied_msg.message_id} у {deleted_count} получателей"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Удаляем исходное сообщение (на которое ответили)
|
||||||
|
try:
|
||||||
|
await replied_msg.delete()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Не удалось удалить исходное сообщение: {e}")
|
||||||
|
|
||||||
|
# Удаляем команду админа
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Не удалось удалить команду админа: {e}")
|
||||||
|
|
||||||
|
# Если было broadcast удаление - показываем статистику
|
||||||
|
if deleted_count > 0:
|
||||||
|
try:
|
||||||
|
status_msg = await message.answer(
|
||||||
|
f"✅ Сообщение удалено у {deleted_count} получателей",
|
||||||
|
reply_to_message_id=None
|
||||||
|
)
|
||||||
|
# Удаляем статус через 3 секунды
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
await status_msg.delete()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Не удалось показать/удалить статус: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при быстром удалении сообщения: {e}")
|
||||||
|
try:
|
||||||
|
# Пытаемся удалить хотя бы команду админа
|
||||||
|
await message.delete()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
343
src/handlers/p2p_chat.py
Normal file
343
src/handlers/p2p_chat.py
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
"""Обработчики P2P чата между пользователями"""
|
||||||
|
from aiogram import Router, F
|
||||||
|
from aiogram.filters import Command, StateFilter
|
||||||
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from src.core.p2p_services import P2PMessageService
|
||||||
|
from src.core.services import UserService
|
||||||
|
from src.core.models import User
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
from src.core.config import ADMIN_IDS
|
||||||
|
|
||||||
|
|
||||||
|
router = Router(name='p2p_chat_router')
|
||||||
|
|
||||||
|
|
||||||
|
class P2PChatStates(StatesGroup):
|
||||||
|
"""Состояния для P2P чата"""
|
||||||
|
waiting_for_recipient = State() # Ожидание выбора получателя
|
||||||
|
chatting = State() # В процессе переписки с пользователем
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(user_id: int) -> bool:
|
||||||
|
"""Проверка прав администратора"""
|
||||||
|
return user_id in ADMIN_IDS
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("chat"))
|
||||||
|
async def show_chat_menu(message: Message, state: FSMContext):
|
||||||
|
"""
|
||||||
|
Главное меню чата
|
||||||
|
/chat - показать меню с опциями общения
|
||||||
|
"""
|
||||||
|
# Очищаем состояние при входе в меню (выход из диалога)
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
await message.answer("❌ Вы не зарегистрированы. Используйте /start")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Получаем количество непрочитанных сообщений
|
||||||
|
unread_count = await P2PMessageService.get_unread_count(session, user.id)
|
||||||
|
|
||||||
|
# Получаем последние диалоги
|
||||||
|
recent = await P2PMessageService.get_recent_conversations(session, user.id, limit=5)
|
||||||
|
|
||||||
|
text = "💬 <b>Чат</b>\n\n"
|
||||||
|
|
||||||
|
if unread_count > 0:
|
||||||
|
text += f"📨 У вас <b>{unread_count}</b> непрочитанных сообщений\n\n"
|
||||||
|
|
||||||
|
text += "Выберите действие:"
|
||||||
|
|
||||||
|
buttons = [
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="✉️ Написать пользователю",
|
||||||
|
callback_data="p2p:select_user"
|
||||||
|
)],
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="📋 Мои диалоги",
|
||||||
|
callback_data="p2p:my_conversations"
|
||||||
|
)]
|
||||||
|
]
|
||||||
|
|
||||||
|
if is_admin(message.from_user.id):
|
||||||
|
buttons.append([InlineKeyboardButton(
|
||||||
|
text="📢 Написать всем (broadcast)",
|
||||||
|
callback_data="p2p:broadcast"
|
||||||
|
)])
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
text,
|
||||||
|
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "p2p:select_user")
|
||||||
|
async def select_recipient(callback: CallbackQuery, state: FSMContext):
|
||||||
|
"""Выбор получателя для P2P сообщения"""
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
# Получаем всех зарегистрированных пользователей кроме себя
|
||||||
|
users = await UserService.get_all_users(session)
|
||||||
|
users = [u for u in users if u.telegram_id != callback.from_user.id and u.is_registered]
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
await callback.message.edit_text("❌ Нет доступных пользователей для общения")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Создаём кнопки с пользователями (по 1 на строку)
|
||||||
|
buttons = []
|
||||||
|
for user in users[:20]: # Ограничение 20 пользователей на странице
|
||||||
|
display_name = f"@{user.username}" if user.username else user.first_name
|
||||||
|
if user.club_card_number:
|
||||||
|
display_name += f" (карта: {user.club_card_number})"
|
||||||
|
|
||||||
|
buttons.append([InlineKeyboardButton(
|
||||||
|
text=display_name,
|
||||||
|
callback_data=f"p2p:user:{user.id}"
|
||||||
|
)])
|
||||||
|
|
||||||
|
buttons.append([InlineKeyboardButton(
|
||||||
|
text="« Назад",
|
||||||
|
callback_data="p2p:back_to_menu"
|
||||||
|
)])
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"👥 <b>Выберите пользователя:</b>\n\n"
|
||||||
|
"Кликните на пользователя, чтобы начать диалог",
|
||||||
|
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("p2p:user:"))
|
||||||
|
async def start_conversation(callback: CallbackQuery, state: FSMContext):
|
||||||
|
"""Начать диалог с выбранным пользователем"""
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
user_id = int(callback.data.split(":")[2])
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
recipient = await session.get(User, user_id)
|
||||||
|
|
||||||
|
if not recipient:
|
||||||
|
await callback.message.edit_text("❌ Пользователь не найден")
|
||||||
|
return
|
||||||
|
|
||||||
|
sender = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||||
|
|
||||||
|
# Получаем последние 10 сообщений из диалога
|
||||||
|
messages = await P2PMessageService.get_conversation(
|
||||||
|
session,
|
||||||
|
sender.id,
|
||||||
|
recipient.id,
|
||||||
|
limit=10
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохраняем ID получателя в состоянии
|
||||||
|
await state.update_data(recipient_id=recipient.id, recipient_telegram_id=recipient.telegram_id)
|
||||||
|
await state.set_state(P2PChatStates.chatting)
|
||||||
|
|
||||||
|
recipient_name = f"@{recipient.username}" if recipient.username else recipient.first_name
|
||||||
|
|
||||||
|
text = f"💬 <b>Диалог с {recipient_name}</b>\n\n"
|
||||||
|
|
||||||
|
if messages:
|
||||||
|
text += "📝 <b>Последние сообщения:</b>\n\n"
|
||||||
|
for msg in reversed(messages[-5:]): # Последние 5 сообщений
|
||||||
|
sender_name = "Вы" if msg.sender_id == sender.id else recipient_name
|
||||||
|
msg_text = msg.text[:50] + "..." if msg.text and len(msg.text) > 50 else (msg.text or f"[{msg.message_type}]")
|
||||||
|
text += f"• {sender_name}: {msg_text}\n"
|
||||||
|
text += "\n"
|
||||||
|
|
||||||
|
text += "✍️ Отправьте сообщение (текст, фото, видео...)\n\n"
|
||||||
|
text += "⚠️ <b>Важно:</b> В режиме диалога все сообщения отправляются только собеседнику.\n"
|
||||||
|
text += "Для выхода в общий чат используйте кнопку ниже или команду /chat"
|
||||||
|
|
||||||
|
buttons = [[InlineKeyboardButton(
|
||||||
|
text="« Завершить диалог",
|
||||||
|
callback_data="p2p:end_conversation"
|
||||||
|
)]]
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text,
|
||||||
|
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "p2p:my_conversations")
|
||||||
|
async def show_conversations(callback: CallbackQuery):
|
||||||
|
"""Показать список диалогов"""
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||||
|
|
||||||
|
conversations = await P2PMessageService.get_recent_conversations(session, user.id, limit=10)
|
||||||
|
|
||||||
|
if not conversations:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"📭 У вас пока нет диалогов\n\n"
|
||||||
|
"Используйте /chat чтобы написать кому-нибудь"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = "📋 <b>Ваши диалоги:</b>\n\n"
|
||||||
|
|
||||||
|
buttons = []
|
||||||
|
for peer, last_msg, unread in conversations:
|
||||||
|
peer_name = f"@{peer.username}" if peer.username else peer.first_name
|
||||||
|
|
||||||
|
# Иконка в зависимости от непрочитанных
|
||||||
|
icon = "🔴" if unread > 0 else "💬"
|
||||||
|
|
||||||
|
# Превью последнего сообщения
|
||||||
|
preview = last_msg.text[:30] + "..." if last_msg.text and len(last_msg.text) > 30 else (last_msg.text or f"[{last_msg.message_type}]")
|
||||||
|
|
||||||
|
button_text = f"{icon} {peer_name}"
|
||||||
|
if unread > 0:
|
||||||
|
button_text += f" ({unread})"
|
||||||
|
|
||||||
|
buttons.append([InlineKeyboardButton(
|
||||||
|
text=button_text,
|
||||||
|
callback_data=f"p2p:user:{peer.id}"
|
||||||
|
)])
|
||||||
|
|
||||||
|
text += f"{icon} <b>{peer_name}</b>\n"
|
||||||
|
text += f" {preview}\n"
|
||||||
|
if unread > 0:
|
||||||
|
text += f" 📨 Непрочитанных: {unread}\n"
|
||||||
|
text += "\n"
|
||||||
|
|
||||||
|
buttons.append([InlineKeyboardButton(
|
||||||
|
text="« Назад",
|
||||||
|
callback_data="p2p:back_to_menu"
|
||||||
|
)])
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text,
|
||||||
|
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "p2p:end_conversation")
|
||||||
|
async def end_conversation(callback: CallbackQuery, state: FSMContext):
|
||||||
|
"""Завершить текущий диалог"""
|
||||||
|
await callback.answer("Диалог завершён")
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"✅ Диалог завершён\n\n"
|
||||||
|
"Используйте /chat чтобы открыть меню чата"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "p2p:back_to_menu")
|
||||||
|
async def back_to_menu(callback: CallbackQuery, state: FSMContext):
|
||||||
|
"""Вернуться в главное меню"""
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
# Имитируем команду /chat
|
||||||
|
fake_message = callback.message
|
||||||
|
fake_message.from_user = callback.from_user
|
||||||
|
|
||||||
|
await show_chat_menu(fake_message, state)
|
||||||
|
|
||||||
|
|
||||||
|
# Обработчик сообщений в состоянии chatting
|
||||||
|
@router.message(StateFilter(P2PChatStates.chatting), F.text | F.photo | F.video | F.document)
|
||||||
|
async def handle_p2p_message(message: Message, state: FSMContext):
|
||||||
|
"""Обработка P2P сообщения от пользователя"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info(f"[P2P] handle_p2p_message вызван: user={message.from_user.id}, в состоянии P2P chatting")
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
recipient_id = data.get("recipient_id")
|
||||||
|
recipient_telegram_id = data.get("recipient_telegram_id")
|
||||||
|
|
||||||
|
if not recipient_id or not recipient_telegram_id:
|
||||||
|
await message.answer("❌ Ошибка: получатель не найден. Начните диалог заново с /chat")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
sender = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||||
|
sender_name = f"@{sender.username}" if sender.username else sender.first_name
|
||||||
|
|
||||||
|
# Определяем тип сообщения
|
||||||
|
message_type = "text"
|
||||||
|
text = message.text
|
||||||
|
file_id = None
|
||||||
|
|
||||||
|
if message.photo:
|
||||||
|
message_type = "photo"
|
||||||
|
file_id = message.photo[-1].file_id
|
||||||
|
text = message.caption
|
||||||
|
elif message.video:
|
||||||
|
message_type = "video"
|
||||||
|
file_id = message.video.file_id
|
||||||
|
text = message.caption
|
||||||
|
elif message.document:
|
||||||
|
message_type = "document"
|
||||||
|
file_id = message.document.file_id
|
||||||
|
text = message.caption
|
||||||
|
|
||||||
|
# Отправляем сообщение получателю
|
||||||
|
try:
|
||||||
|
if message_type == "text":
|
||||||
|
sent = await message.bot.send_message(
|
||||||
|
recipient_telegram_id,
|
||||||
|
f"💬 <b>Сообщение от {sender_name}:</b>\n\n{text}",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message_type == "photo":
|
||||||
|
sent = await message.bot.send_photo(
|
||||||
|
recipient_telegram_id,
|
||||||
|
photo=file_id,
|
||||||
|
caption=f"💬 <b>Фото от {sender_name}</b>\n\n{text or ''}" ,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message_type == "video":
|
||||||
|
sent = await message.bot.send_video(
|
||||||
|
recipient_telegram_id,
|
||||||
|
video=file_id,
|
||||||
|
caption=f"💬 <b>Видео от {sender_name}</b>\n\n{text or ''}",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message_type == "document":
|
||||||
|
sent = await message.bot.send_document(
|
||||||
|
recipient_telegram_id,
|
||||||
|
document=file_id,
|
||||||
|
caption=f"💬 <b>Документ от {sender_name}</b>\n\n{text or ''}",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохраняем в БД
|
||||||
|
await P2PMessageService.send_message(
|
||||||
|
session,
|
||||||
|
sender_id=sender.id,
|
||||||
|
recipient_id=recipient_id,
|
||||||
|
message_type=message_type,
|
||||||
|
text=text,
|
||||||
|
file_id=file_id,
|
||||||
|
sender_message_id=message.message_id,
|
||||||
|
recipient_message_id=sent.message_id
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer("✅ Сообщение доставлено")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await message.answer(f"❌ Не удалось доставить сообщение: {e}")
|
||||||
@@ -131,8 +131,8 @@ class IBotController(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def handle_admin_panel(self, callback):
|
async def handle_active_lotteries(self, callback):
|
||||||
"""Обработать admin panel"""
|
"""Обработать показ активных розыгрышей"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -154,26 +154,11 @@ class IKeyboardBuilder(ABC):
|
|||||||
"""Интерфейс создания клавиатур"""
|
"""Интерфейс создания клавиатур"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_main_keyboard(self, is_admin: bool):
|
def get_main_keyboard(self, is_admin: bool, is_registered: bool = False):
|
||||||
"""Получить главную клавиатуру"""
|
"""Получить главную клавиатуру"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_admin_keyboard(self):
|
def get_admin_keyboard(self):
|
||||||
"""Получить админскую клавиатуру"""
|
"""Получить админскую клавиатуру"""
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_lottery_keyboard(self, lottery_id: int, is_admin: bool):
|
|
||||||
"""Получить клавиатуру для розыгрыша"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_lottery_management_keyboard(self):
|
|
||||||
"""Получить клавиатуру управления розыгрышами"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_conduct_lottery_keyboard(self, lotteries: List[Lottery]):
|
|
||||||
"""Получить клавиатуру для выбора розыгрыша для проведения"""
|
|
||||||
pass
|
pass
|
||||||
@@ -99,26 +99,106 @@ def mask_account_number(account_number: str, show_last_digits: int = 4) -> str:
|
|||||||
|
|
||||||
def parse_accounts_from_message(text: str) -> List[str]:
|
def parse_accounts_from_message(text: str) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Извлекает все валидные номера счетов из текста сообщения
|
Извлекает все валидные номера счетов из текста сообщения.
|
||||||
|
Поддерживает формат: "КАРТА СЧЕТ" (например "2521 11-22-33-44-55-66-77")
|
||||||
|
или просто "СЧЕТ" (например "11-22-33-44-55-66-77")
|
||||||
|
|
||||||
|
Также обрабатывает многострочный текст из кабинета:
|
||||||
|
Запись начинается со слова "Viposnova" и содержит несколько строк до следующего "Viposnova":
|
||||||
|
"Viposnova 16-11-2025 22:19:36
|
||||||
|
17-24-66-42-38-31-53
|
||||||
|
0.00 2918"
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Текст сообщения
|
text: Текст сообщения
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List[str]: Список найденных и отформатированных номеров счетов
|
List[str]: Список найденных строк (может включать номер карты и счета через пробел)
|
||||||
"""
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
accounts = []
|
accounts = []
|
||||||
# Ищем паттерны счетов в тексте (7 пар цифр)
|
|
||||||
pattern = r'\b\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}\b'
|
|
||||||
matches = re.findall(pattern, text)
|
|
||||||
|
|
||||||
for match in matches:
|
# Группируем строки по записям (от "Viposnova" до следующего "Viposnova")
|
||||||
formatted = format_account_number(match)
|
lines = text.strip().split('\n')
|
||||||
if formatted and formatted not in accounts:
|
current_record = []
|
||||||
accounts.append(formatted)
|
records = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
# Если строка начинается с Viposnova и у нас уже есть текущая запись - сохраняем её
|
||||||
|
if stripped.startswith('Viposnova') and current_record:
|
||||||
|
records.append(' '.join(current_record))
|
||||||
|
current_record = [stripped]
|
||||||
|
else:
|
||||||
|
current_record.append(stripped)
|
||||||
|
|
||||||
|
# Добавляем последнюю запись
|
||||||
|
if current_record:
|
||||||
|
records.append(' '.join(current_record))
|
||||||
|
|
||||||
|
# Обрабатываем каждую запись
|
||||||
|
for record in records:
|
||||||
|
parts = record.split()
|
||||||
|
|
||||||
|
# Ищем счет в записи
|
||||||
|
account_number = None
|
||||||
|
account_idx = None
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
if re.match(r'^\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$', part):
|
||||||
|
account_number = format_account_number(part)
|
||||||
|
account_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if not account_number or account_number in accounts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ищем клубную карту (4-значное число после счета)
|
||||||
|
card = None
|
||||||
|
if account_idx is not None:
|
||||||
|
for j in range(account_idx + 1, len(parts)):
|
||||||
|
if re.match(r'^\d{4}$', parts[j]):
|
||||||
|
card = parts[j]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Добавляем результат
|
||||||
|
if card:
|
||||||
|
full_account = f"{card} {account_number}"
|
||||||
|
if full_account not in accounts:
|
||||||
|
accounts.append(full_account)
|
||||||
|
else:
|
||||||
|
accounts.append(account_number)
|
||||||
|
|
||||||
|
# Если построчная обработка ничего не нашла, используем старый метод
|
||||||
|
if not accounts:
|
||||||
|
# Паттерн 1: номер карты (4 цифры) + пробел + счет (7 пар цифр)
|
||||||
|
pattern_with_card = r'(\d{4})\s+(\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2})'
|
||||||
|
|
||||||
|
# Находим все совпадения с картой и удаляем их из текста
|
||||||
|
text_copy = text
|
||||||
|
for match in re.finditer(pattern_with_card, text):
|
||||||
|
card = match.group(1)
|
||||||
|
account = match.group(2)
|
||||||
|
formatted = format_account_number(account)
|
||||||
|
if formatted:
|
||||||
|
full_account = f"{card} {formatted}"
|
||||||
|
if full_account not in accounts:
|
||||||
|
accounts.append(full_account)
|
||||||
|
# Удаляем это совпадение из копии текста, чтобы не найти повторно
|
||||||
|
text_copy = text_copy.replace(match.group(0), ' ' * len(match.group(0)))
|
||||||
|
|
||||||
|
# Паттерн 2: только счет (7 пар цифр) в оставшемся тексте
|
||||||
|
pattern_only_account = r'\b\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}\b'
|
||||||
|
matches_only = re.findall(pattern_only_account, text_copy)
|
||||||
|
|
||||||
|
for match in matches_only:
|
||||||
|
formatted = format_account_number(match)
|
||||||
|
if formatted and formatted not in accounts:
|
||||||
|
# Дополнительная проверка - этот счет не должен быть частью уже найденных "карта + счет"
|
||||||
|
is_duplicate = any(formatted in acc for acc in accounts)
|
||||||
|
if not is_duplicate:
|
||||||
|
accounts.append(formatted)
|
||||||
|
|
||||||
return accounts
|
return accounts
|
||||||
|
|
||||||
|
|||||||
138
src/utils/notifications.py
Normal file
138
src/utils/notifications.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""
|
||||||
|
Модуль для отправки уведомлений победителям
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from ..core.models import Winner, User
|
||||||
|
from ..core.services import LotteryService
|
||||||
|
from ..core.registration_services import AccountService, WinnerNotificationService
|
||||||
|
from ..core.config import ADMIN_IDS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_winners_async(bot: Bot, session: AsyncSession, lottery_id: int):
|
||||||
|
"""
|
||||||
|
Асинхронно отправить уведомления победителям с кнопкой подтверждения.
|
||||||
|
Вызывается после проведения розыгрыша.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot: Экземпляр бота для отправки сообщений
|
||||||
|
session: Сессия БД
|
||||||
|
lottery_id: ID розыгрыша
|
||||||
|
"""
|
||||||
|
# Получаем информацию о розыгрыше
|
||||||
|
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||||
|
if not lottery:
|
||||||
|
logger.error(f"Розыгрыш {lottery_id} не найден")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Получаем всех победителей из БД
|
||||||
|
winners_result = await session.execute(
|
||||||
|
select(Winner).where(Winner.lottery_id == lottery_id)
|
||||||
|
)
|
||||||
|
winners = winners_result.scalars().all()
|
||||||
|
|
||||||
|
logger.info(f"Найдено {len(winners)} победителей для розыгрыша {lottery_id}")
|
||||||
|
|
||||||
|
for winner in winners:
|
||||||
|
try:
|
||||||
|
# Если у победителя есть account_number, ищем владельца
|
||||||
|
if winner.account_number:
|
||||||
|
owner = await AccountService.get_account_owner(session, winner.account_number)
|
||||||
|
|
||||||
|
if owner and owner.telegram_id:
|
||||||
|
# Создаем токен верификации
|
||||||
|
verification = await WinnerNotificationService.create_verification_token(
|
||||||
|
session,
|
||||||
|
winner.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Формируем сообщение с кнопкой подтверждения
|
||||||
|
message = (
|
||||||
|
f"🎉 **Поздравляем! Ваш счет выиграл!**\n\n"
|
||||||
|
f"🎯 Розыгрыш: {lottery.title}\n"
|
||||||
|
f"🏆 Место: {winner.place}\n"
|
||||||
|
f"🎁 Приз: {winner.prize}\n"
|
||||||
|
f"💳 **Выигрышный счет: {winner.account_number}**\n\n"
|
||||||
|
f"⏰ **У вас есть 24 часа для подтверждения!**\n\n"
|
||||||
|
f"Нажмите кнопку ниже, чтобы подтвердить получение приза по этому счету.\n"
|
||||||
|
f"Если вы не подтвердите в течение 24 часов, "
|
||||||
|
f"приз будет разыгран заново.\n\n"
|
||||||
|
f"ℹ️ Если у вас несколько выигрышных счетов, "
|
||||||
|
f"подтвердите каждый из них отдельно."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Создаем кнопку подтверждения с указанием счета
|
||||||
|
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text=f"✅ Подтвердить счет {winner.account_number}",
|
||||||
|
callback_data=f"confirm_win_{winner.id}"
|
||||||
|
)],
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="📞 Связаться с администратором",
|
||||||
|
url=f"tg://user?id={ADMIN_IDS[0]}"
|
||||||
|
)]
|
||||||
|
])
|
||||||
|
|
||||||
|
# Отправляем уведомление с кнопкой
|
||||||
|
await bot.send_message(
|
||||||
|
owner.telegram_id,
|
||||||
|
message,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Отмечаем, что уведомление отправлено
|
||||||
|
winner.is_notified = True
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"✅ Отправлено уведомление победителю {owner.telegram_id} за счет {winner.account_number}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Владелец счета {winner.account_number} не найден или нет telegram_id")
|
||||||
|
|
||||||
|
# Если победитель - обычный пользователь (старая система)
|
||||||
|
elif winner.user_id:
|
||||||
|
user_result = await session.execute(
|
||||||
|
select(User).where(User.id == winner.user_id)
|
||||||
|
)
|
||||||
|
user = user_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user and user.telegram_id:
|
||||||
|
message = (
|
||||||
|
f"🎉 Поздравляем! Вы выиграли!\n\n"
|
||||||
|
f"🎯 Розыгрыш: {lottery.title}\n"
|
||||||
|
f"🏆 Место: {winner.place}\n"
|
||||||
|
f"🎁 Приз: {winner.prize}\n\n"
|
||||||
|
f"Свяжитесь с администратором для получения приза."
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="📞 Связаться с администратором",
|
||||||
|
url=f"tg://user?id={ADMIN_IDS[0]}"
|
||||||
|
)]
|
||||||
|
])
|
||||||
|
|
||||||
|
await bot.send_message(
|
||||||
|
user.telegram_id,
|
||||||
|
message,
|
||||||
|
reply_markup=keyboard
|
||||||
|
)
|
||||||
|
|
||||||
|
winner.is_notified = True
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"✅ Отправлено уведомление победителю {user.telegram_id} (user_id={user.id})")
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Пользователь {winner.user_id} не найден или нет telegram_id")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при отправке уведомления победителю {winner.id}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Завершена отправка уведомлений для розыгрыша {lottery_id}")
|
||||||
21
test_accounts.txt
Normal file
21
test_accounts.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
2521 11-22-33-44-55-66-77
|
||||||
|
2521 12-23-34-45-56-67-78
|
||||||
|
2521 13-24-35-46-57-68-79
|
||||||
|
2521 14-25-36-47-58-69-80
|
||||||
|
2521 15-26-37-48-59-70-81
|
||||||
|
2521 16-27-38-49-60-71-82
|
||||||
|
2521 17-28-39-50-61-72-83
|
||||||
|
2521 18-29-40-51-62-73-84
|
||||||
|
2521 19-30-41-52-63-74-85
|
||||||
|
2521 20-31-42-53-64-75-86
|
||||||
|
|
||||||
|
2522 21-32-43-54-65-76-87
|
||||||
|
2522 22-33-44-55-66-77-88
|
||||||
|
2522 23-34-45-56-67-78-89
|
||||||
|
2522 24-35-46-57-68-79-90
|
||||||
|
2522 25-36-47-58-69-80-91
|
||||||
|
2522 26-37-48-59-70-81-92
|
||||||
|
2522 27-38-49-60-71-82-93
|
||||||
|
2522 28-39-50-61-72-83-94
|
||||||
|
2522 29-40-51-62-73-84-95
|
||||||
|
2522 30-41-52-63-74-85-96
|
||||||
100
test_accounts_100.txt
Normal file
100
test_accounts_100.txt
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
2524 13-44-65-38-31-54-67
|
||||||
|
2523 31-91-70-64-88-67-03
|
||||||
|
2525 21-87-28-91-13-49-61
|
||||||
|
2523 35-22-65-25-15-99-32
|
||||||
|
2525 12-72-37-11-82-58-23
|
||||||
|
2525 96-39-53-66-81-43-28
|
||||||
|
2522 31-19-65-97-82-87-06
|
||||||
|
2521 54-03-08-21-52-27-86
|
||||||
|
2525 42-85-32-06-39-68-81
|
||||||
|
2522 94-50-44-81-24-67-25
|
||||||
|
28-66-94-77-24-23-40
|
||||||
|
72-64-73-89-62-11-90
|
||||||
|
2522 12-25-21-03-46-98-22
|
||||||
|
2524 54-06-23-93-94-44-50
|
||||||
|
2523 23-61-39-40-29-15-28
|
||||||
|
2525 13-85-23-66-37-16-95
|
||||||
|
2525 97-28-72-80-14-30-78
|
||||||
|
2525 11-69-37-13-79-35-12
|
||||||
|
89-44-47-63-67-54-12
|
||||||
|
2525 07-09-98-78-15-23-50
|
||||||
|
2523 05-03-90-01-62-57-18
|
||||||
|
65-07-18-74-28-42-66
|
||||||
|
2525 39-77-17-98-01-23-29
|
||||||
|
2522 05-50-21-93-79-11-61
|
||||||
|
2525 61-18-20-81-60-90-05
|
||||||
|
2521 15-92-74-93-64-78-54
|
||||||
|
2523 22-21-96-99-90-45-27
|
||||||
|
2521 30-97-48-67-95-75-79
|
||||||
|
2524 39-57-99-03-13-46-35
|
||||||
|
2522 98-54-80-56-33-65-44
|
||||||
|
20-91-91-30-15-65-25
|
||||||
|
98-04-80-73-50-11-42
|
||||||
|
98-34-41-64-88-01-63
|
||||||
|
2525 29-35-02-04-32-78-51
|
||||||
|
2523 62-44-20-56-62-78-01
|
||||||
|
2524 14-36-17-91-34-91-55
|
||||||
|
2524 17-01-76-83-62-31-93
|
||||||
|
04-44-22-26-04-55-87
|
||||||
|
2523 11-43-07-89-40-00-88
|
||||||
|
2521 84-28-72-28-33-60-44
|
||||||
|
2525 95-40-78-88-00-43-13
|
||||||
|
2522 69-21-29-41-81-96-77
|
||||||
|
2524 37-22-41-64-08-13-92
|
||||||
|
2524 73-96-94-27-64-09-09
|
||||||
|
33-27-89-47-46-62-85
|
||||||
|
2523 75-75-48-01-28-10-88
|
||||||
|
72-57-79-14-18-91-23
|
||||||
|
98-32-02-86-87-59-11
|
||||||
|
97-19-28-45-03-08-64
|
||||||
|
2523 74-22-18-22-46-58-94
|
||||||
|
2525 18-13-73-83-02-10-09
|
||||||
|
2523 41-15-99-26-09-14-97
|
||||||
|
2525 43-58-60-55-40-73-67
|
||||||
|
2523 42-97-48-61-70-60-38
|
||||||
|
80-70-44-15-17-55-49
|
||||||
|
2522 76-81-33-86-19-53-45
|
||||||
|
2525 45-94-04-45-89-90-28
|
||||||
|
2522 20-97-12-37-10-83-76
|
||||||
|
2524 34-32-51-50-78-80-97
|
||||||
|
2522 30-97-39-84-02-45-49
|
||||||
|
83-67-91-16-68-14-66
|
||||||
|
94-71-04-28-57-75-45
|
||||||
|
2524 83-82-42-15-67-91-48
|
||||||
|
2523 97-98-88-10-36-79-53
|
||||||
|
41-22-09-70-75-40-57
|
||||||
|
2522 77-94-56-22-88-02-16
|
||||||
|
2525 43-11-72-35-15-47-04
|
||||||
|
2525 35-57-25-41-26-07-37
|
||||||
|
57-06-88-62-15-34-66
|
||||||
|
2525 98-66-63-02-15-71-13
|
||||||
|
58-20-77-41-06-52-33
|
||||||
|
2521 11-98-92-27-38-94-75
|
||||||
|
2525 09-48-71-70-71-41-26
|
||||||
|
2525 79-05-30-49-24-22-33
|
||||||
|
26-70-94-22-64-89-48
|
||||||
|
2524 34-71-40-14-68-80-57
|
||||||
|
18-87-93-44-52-37-69
|
||||||
|
2524 09-39-78-85-80-17-81
|
||||||
|
2521 32-08-76-43-59-61-14
|
||||||
|
2523 93-56-87-85-14-53-72
|
||||||
|
2521 78-51-66-89-56-33-49
|
||||||
|
2522 20-24-45-32-47-44-53
|
||||||
|
41-37-43-28-56-43-54
|
||||||
|
2525 95-88-82-26-44-81-83
|
||||||
|
95-26-50-93-40-82-27
|
||||||
|
2521 32-43-09-99-96-51-73
|
||||||
|
2522 62-54-92-00-89-19-66
|
||||||
|
2525 28-53-29-95-71-21-66
|
||||||
|
2523 68-33-54-40-40-99-32
|
||||||
|
2523 60-51-93-71-70-19-35
|
||||||
|
2524 01-72-11-22-48-64-15
|
||||||
|
80-56-98-36-74-46-98
|
||||||
|
2524 08-02-36-94-18-37-27
|
||||||
|
2524 33-98-00-04-99-88-91
|
||||||
|
2523 90-77-79-06-91-29-07
|
||||||
|
2521 63-16-29-62-15-87-98
|
||||||
|
2522 61-37-16-90-50-14-83
|
||||||
|
2521 52-13-01-97-57-81-05
|
||||||
|
29-11-89-59-59-44-05
|
||||||
|
96-42-02-79-02-80-82
|
||||||
1000
test_accounts_1000.txt
Normal file
1000
test_accounts_1000.txt
Normal file
File diff suppressed because it is too large
Load Diff
2000
test_accounts_2000.txt
Normal file
2000
test_accounts_2000.txt
Normal file
File diff suppressed because it is too large
Load Diff
500
test_accounts_500.txt
Normal file
500
test_accounts_500.txt
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
2524 88-62-46-84-72-08-35
|
||||||
|
2522 10-22-27-22-58-78-17
|
||||||
|
51-13-02-75-49-33-24
|
||||||
|
70-89-01-27-80-15-07
|
||||||
|
34-92-77-76-25-70-93
|
||||||
|
38-32-72-86-17-33-56
|
||||||
|
87-60-70-50-25-91-84
|
||||||
|
2523 21-14-04-05-19-46-25
|
||||||
|
2524 89-84-04-85-69-48-11
|
||||||
|
2524 50-35-99-27-26-02-20
|
||||||
|
2523 28-62-92-35-74-98-25
|
||||||
|
2522 93-14-72-96-97-42-96
|
||||||
|
2525 25-30-32-74-67-29-85
|
||||||
|
2521 36-86-88-64-61-88-89
|
||||||
|
2522 44-74-59-58-15-14-89
|
||||||
|
01-30-55-20-38-31-72
|
||||||
|
71-18-33-96-66-96-26
|
||||||
|
2524 94-32-58-56-35-13-97
|
||||||
|
2523 28-87-80-20-45-21-05
|
||||||
|
2524 72-50-32-62-44-95-03
|
||||||
|
2522 25-60-16-18-19-11-70
|
||||||
|
98-79-01-28-64-95-66
|
||||||
|
96-26-10-27-17-87-71
|
||||||
|
23-99-31-56-74-73-76
|
||||||
|
88-78-77-67-55-73-96
|
||||||
|
95-27-40-11-78-13-64
|
||||||
|
81-54-62-27-54-62-69
|
||||||
|
31-94-80-51-25-36-79
|
||||||
|
2524 44-10-96-63-84-30-07
|
||||||
|
2522 53-36-32-70-62-28-43
|
||||||
|
2523 60-82-65-57-94-68-25
|
||||||
|
2523 90-62-99-58-03-02-57
|
||||||
|
84-93-24-28-61-92-83
|
||||||
|
38-97-88-51-57-47-91
|
||||||
|
2522 97-18-71-19-17-46-11
|
||||||
|
74-25-19-72-73-69-05
|
||||||
|
2523 96-41-78-01-63-40-13
|
||||||
|
2525 93-75-84-73-30-84-68
|
||||||
|
2523 29-78-54-03-00-21-31
|
||||||
|
2524 74-45-78-17-55-77-54
|
||||||
|
2525 42-11-31-48-56-32-88
|
||||||
|
2525 69-47-22-59-62-43-20
|
||||||
|
2523 01-22-13-57-05-25-44
|
||||||
|
2525 59-22-43-08-53-48-82
|
||||||
|
2525 32-15-12-73-96-25-50
|
||||||
|
2525 90-04-74-22-33-88-10
|
||||||
|
2522 30-32-71-15-43-34-55
|
||||||
|
30-92-54-05-94-53-54
|
||||||
|
2525 29-58-08-99-46-04-29
|
||||||
|
46-27-64-43-09-37-58
|
||||||
|
2525 77-95-40-98-58-08-54
|
||||||
|
2525 02-66-43-02-60-18-34
|
||||||
|
16-37-17-50-65-63-51
|
||||||
|
28-00-31-28-74-01-13
|
||||||
|
2521 18-65-37-13-86-46-08
|
||||||
|
2524 88-84-69-86-18-46-49
|
||||||
|
25-23-65-85-03-80-42
|
||||||
|
2523 10-64-29-31-20-89-52
|
||||||
|
2524 65-21-51-30-91-21-68
|
||||||
|
33-24-81-00-31-10-06
|
||||||
|
2522 66-21-20-66-66-77-70
|
||||||
|
64-36-82-81-22-07-90
|
||||||
|
2524 59-29-33-33-51-95-17
|
||||||
|
2523 00-93-53-78-54-23-22
|
||||||
|
2522 73-77-13-34-10-90-73
|
||||||
|
2521 80-60-56-32-06-52-22
|
||||||
|
61-17-66-25-81-17-53
|
||||||
|
2524 60-47-94-82-73-16-91
|
||||||
|
2524 42-23-08-47-92-68-73
|
||||||
|
2523 96-42-17-80-54-21-92
|
||||||
|
43-41-24-82-73-89-70
|
||||||
|
58-59-94-04-58-25-95
|
||||||
|
65-09-40-69-61-49-66
|
||||||
|
2524 50-80-86-64-00-07-03
|
||||||
|
2525 49-88-90-85-64-35-76
|
||||||
|
2524 45-24-80-26-42-84-59
|
||||||
|
2524 95-24-66-37-33-61-07
|
||||||
|
2523 49-58-55-29-51-10-61
|
||||||
|
2525 39-03-45-88-41-32-53
|
||||||
|
2523 73-96-56-70-51-13-71
|
||||||
|
65-18-22-20-11-92-26
|
||||||
|
2525 80-30-71-96-23-95-74
|
||||||
|
2521 68-19-86-32-40-86-59
|
||||||
|
2522 07-03-45-99-77-61-66
|
||||||
|
2522 53-26-95-59-95-36-13
|
||||||
|
2525 41-02-61-74-69-53-72
|
||||||
|
2521 40-42-28-13-59-79-73
|
||||||
|
2522 03-31-84-02-95-87-67
|
||||||
|
04-54-85-07-18-08-63
|
||||||
|
2522 51-18-39-20-56-42-88
|
||||||
|
2525 90-88-19-93-08-36-74
|
||||||
|
2522 23-14-28-13-65-76-55
|
||||||
|
2523 24-89-75-22-08-30-07
|
||||||
|
15-26-21-64-07-12-45
|
||||||
|
2521 79-89-45-51-27-87-84
|
||||||
|
2525 01-11-24-63-37-93-77
|
||||||
|
2524 81-41-39-29-85-72-75
|
||||||
|
2525 64-96-76-67-37-51-52
|
||||||
|
21-31-25-17-61-80-92
|
||||||
|
2524 72-32-21-73-93-88-48
|
||||||
|
84-27-78-23-47-96-13
|
||||||
|
2523 52-86-55-42-99-36-96
|
||||||
|
2524 10-33-99-48-82-51-25
|
||||||
|
95-69-56-50-65-47-42
|
||||||
|
2525 99-89-69-98-27-91-33
|
||||||
|
06-20-51-97-71-00-53
|
||||||
|
2522 22-05-43-81-46-67-40
|
||||||
|
2521 37-08-49-25-33-08-77
|
||||||
|
2524 63-03-27-24-77-20-41
|
||||||
|
65-59-99-21-28-67-74
|
||||||
|
51-89-42-53-15-48-48
|
||||||
|
41-60-33-82-91-19-40
|
||||||
|
2522 47-26-52-13-21-61-61
|
||||||
|
32-81-00-16-63-90-66
|
||||||
|
2524 18-12-12-11-89-20-60
|
||||||
|
2522 29-93-53-71-59-57-17
|
||||||
|
2522 17-61-02-56-63-48-90
|
||||||
|
2522 87-56-66-57-13-34-32
|
||||||
|
27-43-61-72-26-68-94
|
||||||
|
2525 15-74-04-57-85-46-89
|
||||||
|
2525 58-35-93-12-58-24-84
|
||||||
|
41-09-96-02-81-97-85
|
||||||
|
04-92-76-03-21-36-38
|
||||||
|
36-82-09-76-50-91-40
|
||||||
|
2521 31-48-77-83-23-85-58
|
||||||
|
91-08-41-12-22-67-92
|
||||||
|
2525 91-01-95-06-20-56-66
|
||||||
|
2523 92-09-07-53-90-73-56
|
||||||
|
2523 24-88-11-05-06-18-63
|
||||||
|
2525 14-89-03-92-45-65-53
|
||||||
|
2523 73-98-00-08-94-74-60
|
||||||
|
11-25-05-77-54-25-38
|
||||||
|
2525 24-14-14-61-13-96-41
|
||||||
|
28-33-55-89-06-90-31
|
||||||
|
2523 92-90-32-07-42-96-04
|
||||||
|
2525 79-80-48-56-75-29-12
|
||||||
|
2521 77-97-88-83-04-44-09
|
||||||
|
2523 82-96-37-98-15-52-75
|
||||||
|
2522 64-34-21-10-96-85-39
|
||||||
|
2524 31-52-64-02-96-39-16
|
||||||
|
03-50-03-64-37-62-21
|
||||||
|
2521 49-63-37-97-53-63-00
|
||||||
|
2525 94-49-52-77-74-48-81
|
||||||
|
55-40-74-74-81-86-50
|
||||||
|
2524 06-70-54-03-82-67-17
|
||||||
|
75-19-75-29-43-35-82
|
||||||
|
2521 42-96-95-66-89-84-01
|
||||||
|
2521 55-33-17-44-67-26-89
|
||||||
|
2524 56-64-65-06-52-00-85
|
||||||
|
2522 93-66-95-15-90-23-90
|
||||||
|
2523 31-25-99-15-61-01-30
|
||||||
|
2525 54-54-54-47-69-06-33
|
||||||
|
2525 17-40-02-42-79-86-21
|
||||||
|
2522 21-12-01-11-51-55-14
|
||||||
|
2521 46-20-64-13-21-06-15
|
||||||
|
2523 92-85-71-89-97-70-84
|
||||||
|
2523 22-84-47-04-78-47-01
|
||||||
|
62-49-03-81-98-15-91
|
||||||
|
2524 79-54-71-16-36-91-63
|
||||||
|
2522 02-11-79-98-69-92-57
|
||||||
|
2525 32-76-56-57-96-23-90
|
||||||
|
2523 06-87-57-07-02-01-85
|
||||||
|
2521 18-35-94-83-28-73-15
|
||||||
|
2523 97-04-86-66-40-64-86
|
||||||
|
2521 55-97-94-59-99-20-57
|
||||||
|
2525 18-46-50-17-69-33-41
|
||||||
|
2522 09-48-99-58-34-13-61
|
||||||
|
2523 28-82-53-71-21-05-09
|
||||||
|
2523 08-12-90-23-74-10-27
|
||||||
|
2525 32-08-45-22-72-72-76
|
||||||
|
60-67-63-50-96-10-27
|
||||||
|
2525 75-03-19-97-62-80-88
|
||||||
|
2522 97-86-67-50-27-37-08
|
||||||
|
49-08-22-06-86-17-86
|
||||||
|
2524 09-80-21-70-82-91-48
|
||||||
|
96-06-92-25-94-08-57
|
||||||
|
2525 21-35-94-03-85-72-61
|
||||||
|
2521 39-93-53-66-86-81-96
|
||||||
|
2524 06-18-23-18-88-94-09
|
||||||
|
2521 52-96-14-51-04-51-36
|
||||||
|
2522 10-62-26-66-78-03-94
|
||||||
|
2525 58-22-74-01-66-37-97
|
||||||
|
2524 22-82-49-98-55-97-36
|
||||||
|
2523 04-16-77-51-80-89-13
|
||||||
|
70-51-03-12-10-26-56
|
||||||
|
2521 80-93-55-85-90-06-27
|
||||||
|
2525 18-63-31-58-45-52-61
|
||||||
|
17-10-85-46-30-32-82
|
||||||
|
73-84-60-73-28-53-48
|
||||||
|
2521 13-98-24-82-40-06-10
|
||||||
|
2521 58-59-74-00-18-34-85
|
||||||
|
2524 92-02-64-75-83-14-50
|
||||||
|
10-26-44-71-18-12-71
|
||||||
|
2523 25-09-58-53-10-53-54
|
||||||
|
2521 34-51-86-52-12-41-76
|
||||||
|
2522 71-42-30-72-71-45-59
|
||||||
|
2524 00-71-32-40-12-45-68
|
||||||
|
2524 74-50-48-06-05-52-06
|
||||||
|
48-88-23-94-23-40-74
|
||||||
|
2525 91-22-15-04-72-70-70
|
||||||
|
2521 76-78-90-23-44-92-83
|
||||||
|
2525 57-39-63-94-24-69-04
|
||||||
|
14-88-43-54-27-70-11
|
||||||
|
2522 18-25-25-91-36-53-23
|
||||||
|
2524 36-15-88-30-21-64-83
|
||||||
|
2525 66-11-70-60-37-02-63
|
||||||
|
43-11-84-99-73-28-48
|
||||||
|
01-03-64-24-84-70-15
|
||||||
|
2524 48-76-97-28-23-64-71
|
||||||
|
2524 77-08-08-23-73-96-22
|
||||||
|
2521 64-02-43-87-85-72-84
|
||||||
|
2525 85-46-13-04-03-63-60
|
||||||
|
2524 56-96-76-02-20-13-95
|
||||||
|
31-54-15-57-42-74-53
|
||||||
|
89-00-93-32-62-12-11
|
||||||
|
45-76-98-25-74-09-04
|
||||||
|
2521 64-30-44-10-39-95-33
|
||||||
|
44-71-95-86-12-54-08
|
||||||
|
63-13-57-14-13-48-16
|
||||||
|
41-87-71-95-17-22-88
|
||||||
|
2521 55-23-84-04-27-20-38
|
||||||
|
2523 80-64-38-39-76-43-04
|
||||||
|
2523 81-83-82-90-45-95-65
|
||||||
|
2523 57-84-88-16-25-30-98
|
||||||
|
2525 78-21-73-66-17-08-23
|
||||||
|
13-96-69-65-56-65-03
|
||||||
|
2522 76-37-07-36-14-56-29
|
||||||
|
2525 25-69-00-04-35-06-73
|
||||||
|
2525 63-19-14-57-67-48-50
|
||||||
|
2521 35-43-79-88-05-41-04
|
||||||
|
2525 24-39-13-22-92-33-38
|
||||||
|
39-87-05-09-65-00-95
|
||||||
|
2522 18-68-83-63-94-11-52
|
||||||
|
59-66-84-42-56-03-62
|
||||||
|
36-35-03-95-91-45-41
|
||||||
|
16-11-69-63-84-39-80
|
||||||
|
04-84-19-52-59-91-38
|
||||||
|
2523 18-18-33-99-33-21-00
|
||||||
|
2524 23-70-82-88-62-37-02
|
||||||
|
2524 84-81-71-58-92-39-45
|
||||||
|
45-37-02-62-10-07-76
|
||||||
|
82-02-00-62-68-89-90
|
||||||
|
2524 86-09-14-71-82-07-96
|
||||||
|
00-46-39-33-52-92-78
|
||||||
|
2522 52-39-25-89-07-07-57
|
||||||
|
2524 84-73-35-01-08-20-67
|
||||||
|
01-20-59-64-93-70-69
|
||||||
|
2521 54-32-02-66-48-17-66
|
||||||
|
2522 27-88-88-20-04-95-37
|
||||||
|
2522 64-20-24-10-80-29-56
|
||||||
|
97-57-32-45-22-40-46
|
||||||
|
96-34-25-40-82-57-74
|
||||||
|
2522 81-31-85-33-45-63-70
|
||||||
|
2524 66-71-41-81-31-98-25
|
||||||
|
49-82-16-11-72-89-45
|
||||||
|
2521 66-43-39-05-15-18-35
|
||||||
|
2525 33-11-45-38-33-86-68
|
||||||
|
2522 98-15-12-20-40-53-38
|
||||||
|
2523 88-42-37-81-18-01-02
|
||||||
|
2521 11-65-99-21-43-15-22
|
||||||
|
53-13-41-07-68-00-08
|
||||||
|
2524 47-73-46-61-53-08-26
|
||||||
|
2523 08-19-28-22-45-02-64
|
||||||
|
2521 44-82-74-93-95-67-71
|
||||||
|
2523 58-08-17-31-34-08-12
|
||||||
|
2525 14-35-43-99-32-32-85
|
||||||
|
16-39-50-48-61-01-68
|
||||||
|
21-01-79-67-64-02-34
|
||||||
|
2523 29-90-42-53-74-49-24
|
||||||
|
43-36-98-42-50-74-58
|
||||||
|
2521 94-81-74-15-33-82-12
|
||||||
|
2525 58-11-35-62-67-84-14
|
||||||
|
51-29-63-65-41-59-61
|
||||||
|
2521 83-82-27-34-21-39-89
|
||||||
|
2524 02-33-52-60-73-83-02
|
||||||
|
98-60-39-67-78-63-16
|
||||||
|
2523 64-01-33-01-30-29-51
|
||||||
|
11-75-71-71-03-02-16
|
||||||
|
2522 26-61-47-07-99-43-61
|
||||||
|
2525 47-52-94-94-22-86-50
|
||||||
|
38-06-39-62-20-43-40
|
||||||
|
2525 35-95-33-15-26-71-68
|
||||||
|
2525 42-85-13-31-42-01-39
|
||||||
|
2522 49-75-29-96-44-83-78
|
||||||
|
77-78-32-83-24-38-75
|
||||||
|
2523 49-04-42-96-56-31-75
|
||||||
|
2525 97-48-18-70-00-51-18
|
||||||
|
44-65-44-13-62-33-58
|
||||||
|
41-59-53-82-42-97-31
|
||||||
|
2525 25-11-42-32-67-02-45
|
||||||
|
71-63-18-02-65-19-04
|
||||||
|
95-17-37-75-09-90-68
|
||||||
|
2524 03-54-07-90-12-65-23
|
||||||
|
80-79-45-70-64-72-68
|
||||||
|
2523 31-58-15-79-76-04-38
|
||||||
|
20-15-21-46-53-62-33
|
||||||
|
2521 36-38-82-78-34-89-65
|
||||||
|
2524 84-20-61-66-19-69-95
|
||||||
|
2525 48-16-40-86-41-78-35
|
||||||
|
2524 03-37-64-84-01-78-94
|
||||||
|
2524 44-67-25-32-81-53-15
|
||||||
|
2525 48-52-48-87-90-98-18
|
||||||
|
30-60-22-87-47-25-15
|
||||||
|
2525 33-84-89-80-86-70-09
|
||||||
|
73-93-46-17-69-91-97
|
||||||
|
2522 84-97-55-42-32-60-92
|
||||||
|
2525 07-07-64-14-63-51-14
|
||||||
|
2524 55-03-93-60-14-91-74
|
||||||
|
2523 32-19-25-22-77-78-15
|
||||||
|
2521 73-53-49-22-54-23-90
|
||||||
|
2521 78-87-15-24-92-85-90
|
||||||
|
2522 34-62-94-56-11-17-51
|
||||||
|
2522 30-07-45-21-59-94-54
|
||||||
|
2523 55-92-76-54-95-29-71
|
||||||
|
76-03-18-42-39-37-30
|
||||||
|
89-26-94-14-17-99-40
|
||||||
|
50-10-05-18-34-97-32
|
||||||
|
2521 04-25-61-71-00-32-50
|
||||||
|
2523 56-82-78-00-94-99-90
|
||||||
|
2524 34-99-74-17-91-98-84
|
||||||
|
75-74-30-25-42-81-71
|
||||||
|
2524 37-69-87-33-41-40-02
|
||||||
|
50-19-15-78-99-25-22
|
||||||
|
18-49-62-94-65-95-87
|
||||||
|
2523 77-16-41-76-81-66-35
|
||||||
|
2522 59-70-39-69-97-92-96
|
||||||
|
2525 81-72-07-51-68-40-23
|
||||||
|
2525 63-60-68-44-43-62-08
|
||||||
|
2521 73-20-40-52-98-97-29
|
||||||
|
2523 38-27-54-83-03-00-26
|
||||||
|
2522 08-39-39-32-25-45-56
|
||||||
|
2523 40-34-67-04-37-33-29
|
||||||
|
2524 11-41-84-92-94-16-33
|
||||||
|
2521 89-55-98-69-20-03-41
|
||||||
|
2521 27-09-16-26-04-82-81
|
||||||
|
2521 38-83-20-21-79-29-81
|
||||||
|
2525 61-09-59-92-28-67-66
|
||||||
|
47-19-80-43-43-20-93
|
||||||
|
2521 87-80-59-51-20-32-74
|
||||||
|
2524 70-14-85-72-40-80-60
|
||||||
|
2523 77-57-03-64-45-21-38
|
||||||
|
2521 88-33-82-62-01-49-55
|
||||||
|
88-11-93-34-85-87-69
|
||||||
|
06-02-35-69-77-05-11
|
||||||
|
2525 84-91-87-54-60-51-46
|
||||||
|
2525 78-99-73-78-24-94-24
|
||||||
|
29-50-87-38-87-93-90
|
||||||
|
2521 84-73-41-32-87-95-52
|
||||||
|
2521 53-62-20-06-17-74-40
|
||||||
|
2524 13-47-06-47-93-65-29
|
||||||
|
2522 38-85-34-37-71-05-30
|
||||||
|
2523 48-39-49-57-23-78-96
|
||||||
|
2522 81-22-48-06-91-47-42
|
||||||
|
15-65-95-20-46-73-48
|
||||||
|
2521 80-46-01-82-74-75-03
|
||||||
|
2521 11-40-88-15-16-96-49
|
||||||
|
2524 43-94-42-84-35-12-17
|
||||||
|
2524 18-12-45-80-30-07-72
|
||||||
|
2525 57-99-35-42-43-67-68
|
||||||
|
63-99-70-67-80-84-31
|
||||||
|
2521 19-80-66-96-16-61-44
|
||||||
|
90-66-93-65-04-32-71
|
||||||
|
52-73-25-85-08-22-10
|
||||||
|
41-42-86-69-91-89-93
|
||||||
|
2525 69-06-01-51-03-59-91
|
||||||
|
2522 25-00-80-31-11-83-55
|
||||||
|
18-77-42-88-77-67-11
|
||||||
|
2525 83-90-27-60-78-24-26
|
||||||
|
2523 94-00-59-37-68-05-50
|
||||||
|
2521 55-74-61-32-63-51-01
|
||||||
|
2522 61-90-85-23-11-51-03
|
||||||
|
2523 94-78-26-87-62-57-55
|
||||||
|
2524 22-42-80-60-85-42-48
|
||||||
|
2521 47-06-03-02-78-96-05
|
||||||
|
2524 78-54-40-11-40-54-75
|
||||||
|
68-20-77-52-00-10-70
|
||||||
|
2521 04-82-37-21-22-19-17
|
||||||
|
2524 62-94-76-61-11-56-75
|
||||||
|
14-04-11-98-47-23-56
|
||||||
|
2521 54-41-86-59-91-91-61
|
||||||
|
14-00-07-96-01-62-04
|
||||||
|
29-18-98-86-00-88-70
|
||||||
|
62-78-07-66-28-68-93
|
||||||
|
23-67-08-74-60-57-55
|
||||||
|
2521 44-26-69-25-31-41-36
|
||||||
|
2523 65-82-68-93-69-64-68
|
||||||
|
25-23-22-44-51-33-19
|
||||||
|
2521 45-37-36-91-84-70-59
|
||||||
|
2521 99-23-86-83-01-62-70
|
||||||
|
85-94-26-28-50-89-75
|
||||||
|
2521 16-30-23-12-48-81-01
|
||||||
|
36-43-94-12-58-24-73
|
||||||
|
2522 22-11-15-28-77-93-46
|
||||||
|
24-00-68-13-80-33-10
|
||||||
|
2524 79-10-22-21-74-10-56
|
||||||
|
2525 50-92-57-27-51-67-57
|
||||||
|
53-28-93-58-39-45-05
|
||||||
|
2522 49-13-78-56-46-96-33
|
||||||
|
2523 65-40-89-45-25-45-78
|
||||||
|
2523 59-35-54-94-01-68-62
|
||||||
|
2521 21-26-28-37-80-04-15
|
||||||
|
31-71-93-03-54-89-84
|
||||||
|
2524 06-16-02-83-98-00-11
|
||||||
|
2524 79-24-11-13-14-02-37
|
||||||
|
2522 08-95-10-92-33-49-44
|
||||||
|
2521 49-65-96-35-05-04-53
|
||||||
|
2522 41-32-18-41-45-88-81
|
||||||
|
2521 53-55-62-25-06-39-43
|
||||||
|
2521 05-14-32-15-50-24-82
|
||||||
|
2525 60-47-47-27-56-11-89
|
||||||
|
2521 44-77-64-51-88-05-75
|
||||||
|
2523 25-51-51-60-61-81-76
|
||||||
|
2523 92-38-26-84-23-01-06
|
||||||
|
28-67-09-28-67-04-31
|
||||||
|
2525 29-39-37-88-09-23-79
|
||||||
|
33-48-56-81-66-84-89
|
||||||
|
23-38-63-69-33-39-02
|
||||||
|
2522 70-04-29-62-18-94-74
|
||||||
|
2524 31-07-43-44-22-06-24
|
||||||
|
2524 58-41-39-65-11-94-61
|
||||||
|
2525 85-80-40-57-39-02-03
|
||||||
|
2524 45-80-38-47-70-95-24
|
||||||
|
82-85-24-60-48-90-50
|
||||||
|
04-03-01-57-35-97-62
|
||||||
|
2524 82-00-55-91-97-52-37
|
||||||
|
2523 97-00-38-05-71-74-38
|
||||||
|
32-09-89-80-29-48-51
|
||||||
|
84-75-37-85-77-75-29
|
||||||
|
2523 51-44-85-74-10-90-74
|
||||||
|
2523 25-63-16-22-75-48-79
|
||||||
|
80-59-44-91-58-46-30
|
||||||
|
2522 31-48-06-26-42-59-84
|
||||||
|
48-50-24-48-30-74-73
|
||||||
|
31-26-27-54-59-28-34
|
||||||
|
2522 87-66-84-15-33-31-95
|
||||||
|
51-85-47-66-51-64-87
|
||||||
|
2523 55-09-83-65-81-58-51
|
||||||
|
2522 99-11-54-41-04-24-54
|
||||||
|
78-44-82-14-91-00-67
|
||||||
|
31-38-18-34-44-79-59
|
||||||
|
2521 75-13-20-65-21-16-15
|
||||||
|
2523 26-44-92-56-41-70-22
|
||||||
|
95-71-53-73-55-50-94
|
||||||
|
10-44-09-45-67-13-75
|
||||||
|
2525 06-21-87-86-54-94-02
|
||||||
|
2524 31-85-09-42-29-45-57
|
||||||
|
2525 42-01-75-05-25-11-40
|
||||||
|
2524 12-14-10-27-19-30-99
|
||||||
|
79-97-04-48-87-42-00
|
||||||
|
2521 90-02-73-89-64-29-10
|
||||||
|
2523 29-17-32-76-08-65-75
|
||||||
|
2524 70-31-69-39-33-84-38
|
||||||
|
2525 71-52-62-55-12-16-57
|
||||||
|
36-69-53-13-49-70-66
|
||||||
|
85-12-10-39-29-80-35
|
||||||
|
2524 26-09-42-08-04-99-55
|
||||||
|
2523 33-23-74-47-43-33-24
|
||||||
|
2525 06-91-79-15-79-29-41
|
||||||
|
60-88-10-40-92-23-52
|
||||||
|
2523 24-05-58-34-80-77-14
|
||||||
|
2522 74-71-28-79-29-38-72
|
||||||
|
2521 80-50-12-20-47-99-78
|
||||||
|
2521 06-83-17-55-45-79-82
|
||||||
|
2521 13-52-26-76-99-70-20
|
||||||
|
2524 84-64-14-58-40-09-62
|
||||||
|
2524 86-97-11-55-57-83-16
|
||||||
|
2522 79-38-56-35-52-07-41
|
||||||
|
91-38-01-67-78-65-73
|
||||||
|
2523 05-11-50-18-20-12-38
|
||||||
|
2521 03-88-90-27-37-15-37
|
||||||
|
2525 83-26-08-00-50-20-68
|
||||||
|
2521 68-65-73-31-70-44-45
|
||||||
|
2524 54-66-91-09-07-74-26
|
||||||
|
2525 72-65-73-73-62-24-96
|
||||||
|
07-41-74-07-86-07-39
|
||||||
|
2522 64-48-93-29-40-97-14
|
||||||
|
2525 79-90-61-88-87-15-59
|
||||||
|
2524 50-47-16-17-09-15-14
|
||||||
|
2521 46-06-40-88-48-85-88
|
||||||
|
91-27-05-71-25-84-20
|
||||||
|
2522 12-22-39-13-04-78-78
|
||||||
|
2525 58-11-44-63-05-97-71
|
||||||
|
2521 70-16-43-07-87-51-85
|
||||||
|
2521 58-92-61-20-12-28-60
|
||||||
|
57-80-24-58-22-03-15
|
||||||
|
2524 12-08-29-52-75-46-34
|
||||||
|
2524 63-17-74-41-08-29-16
|
||||||
|
81-05-91-02-20-96-92
|
||||||
|
96-59-37-84-38-68-85
|
||||||
|
34-09-34-90-82-90-14
|
||||||
|
45-66-92-96-14-48-83
|
||||||
|
2522 01-61-02-21-68-28-60
|
||||||
|
89-01-37-64-20-77-75
|
||||||
|
14-00-50-43-04-66-06
|
||||||
|
2521 06-35-29-40-03-24-19
|
||||||
|
2524 78-34-98-20-72-56-24
|
||||||
|
54-05-64-46-00-00-54
|
||||||
|
87-00-71-87-41-99-40
|
||||||
|
70-50-43-54-84-95-28
|
||||||
|
2524 87-53-38-76-20-49-78
|
||||||
5000
test_accounts_5000.txt
Normal file
5000
test_accounts_5000.txt
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user