feat: Система автоматического подтверждения выигрышей с поддержкой множественных счетов
Some checks reported errors
continuous-integration/drone/push Build encountered an error
Some checks reported errors
continuous-integration/drone/push Build encountered an error
Основные изменения: ✨ Новые функции: - Система регистрации пользователей с множественными счетами - Автоматическое подтверждение выигрышей через inline-кнопки - Механизм переигровки для неподтвержденных выигрышей (24 часа) - Подтверждение на уровне счетов (каждый счет подтверждается отдельно) - Скрипт полной очистки базы данных 🔧 Технические улучшения: - Исправлена ошибка MissingGreenlet при lazy loading (добавлен joinedload/selectinload) - Добавлено поле claimed_at для отслеживания времени подтверждения - Пакетное добавление счетов с выбором розыгрыша - Проверка владения конкретным счетом при подтверждении 📚 Документация: - docs/AUTO_CONFIRM_SYSTEM.md - Полная документация системы подтверждения - docs/ACCOUNT_BASED_CONFIRMATION.md - Подтверждение на уровне счетов - docs/REGISTRATION_SYSTEM.md - Система регистрации - docs/ADMIN_COMMANDS.md - Команды администратора - docs/CLEAR_DATABASE.md - Очистка БД - docs/QUICK_GUIDE.md - Быстрое начало - docs/UPDATE_LOG.md - Журнал обновлений 🗄️ База данных: - Миграция 003: Таблицы accounts, winner_verifications - Миграция 004: Поле claimed_at в таблице winners - Скрипт scripts/clear_database.py для полной очистки 🎮 Новые команды: Админские: - /check_unclaimed <lottery_id> - Проверка неподтвержденных выигрышей - /redraw <lottery_id> - Повторный розыгрыш - /add_accounts - Пакетное добавление счетов - /list_accounts <telegram_id> - Список счетов пользователя Пользовательские: - /register - Регистрация с вводом данных - /my_account - Просмотр своих счетов - Callback confirm_win_{id} - Подтверждение выигрыша 🛠️ Makefile: - make clear-db - Очистка всех данных из БД (с подтверждением) 🔒 Безопасность: - Проверка владения счетом при подтверждении - Защита от подтверждения чужих счетов - Независимое подтверждение каждого выигрышного счета 📊 Логика работы: 1. Пользователь регистрируется и добавляет счета 2. Счета участвуют в розыгрыше 3. Победители получают уведомление с кнопкой подтверждения 4. Каждый счет подтверждается отдельно (24 часа на подтверждение) 5. Неподтвержденные выигрыши переигрываются через /redraw
This commit is contained in:
18
Makefile
18
Makefile
@@ -20,6 +20,7 @@ help:
|
||||
@echo " make stats - Показать статистику"
|
||||
@echo " make demo-admin - Демонстрация админ-панели"
|
||||
@echo " make test-admin - Тестирование улучшений админки"
|
||||
@echo " make clear-db - ⚠️ УДАЛИТЬ ВСЕ ДАННЫЕ из БД"
|
||||
@echo ""
|
||||
@echo "Быстрый старт с PostgreSQL:"
|
||||
@echo " 1. cp .env.example .env"
|
||||
@@ -102,6 +103,23 @@ test-admin:
|
||||
@echo "🧪 Тестирование новых функций админ-панели..."
|
||||
. .venv/bin/activate && python tests/test_admin_improvements.py
|
||||
|
||||
# ⚠️ ОПАСНО: Полная очистка базы данных
|
||||
clear-db:
|
||||
@echo "⚠️ ВНИМАНИЕ! Это удалит ВСЕ данные из базы данных!"
|
||||
@echo " - Все пользователи"
|
||||
@echo " - Все розыгрыши"
|
||||
@echo " - Все счета"
|
||||
@echo " - Все участия"
|
||||
@echo " - Всех победителей"
|
||||
@echo ""
|
||||
@read -p "Вы уверены? Введите 'yes' для подтверждения: " confirm; \
|
||||
if [ "$$confirm" = "yes" ]; then \
|
||||
echo "🗑️ Очистка базы данных..."; \
|
||||
. .venv/bin/activate && python scripts/clear_database.py; \
|
||||
else \
|
||||
echo "❌ Отменено"; \
|
||||
fi
|
||||
|
||||
# Очистка
|
||||
clean:
|
||||
@echo "🧹 Очистка временных файлов..."
|
||||
|
||||
279
docs/ACCOUNT_BASED_CONFIRMATION.md
Normal file
279
docs/ACCOUNT_BASED_CONFIRMATION.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# Подтверждение выигрышей по счетам
|
||||
|
||||
## 🎯 Концепция
|
||||
|
||||
Система подтверждения выигрышей работает **на уровне счетов**, а не пользователей.
|
||||
|
||||
### Основные принципы:
|
||||
|
||||
1. **Один пользователь = Много счетов**
|
||||
- У клиента может быть несколько счетов (11-22-33..., 44-55-66..., и т.д.)
|
||||
- Каждый счет участвует в розыгрыше независимо
|
||||
|
||||
2. **Один счет = Один выигрыш = Одно подтверждение**
|
||||
- Если счет `11-22-33-44-55-66-77` выиграл 1 место - требуется подтверждение
|
||||
- Если счет `44-55-66-77-88-99-00` выиграл 3 место - требуется отдельное подтверждение
|
||||
- Даже если оба счета принадлежат одному клиенту
|
||||
|
||||
3. **Независимое подтверждение**
|
||||
- Каждый выигрышный счет подтверждается отдельной кнопкой
|
||||
- Подтверждение одного счета не влияет на другие
|
||||
- У каждого счета свой 24-часовой лимит
|
||||
|
||||
## 📱 Как это работает для пользователя
|
||||
|
||||
### Сценарий: У клиента 3 счета, выиграли 2
|
||||
|
||||
**Счета клиента:**
|
||||
- `11-22-33-44-55-66-77` ✅ Выиграл 1 место (iPhone 15)
|
||||
- `22-33-44-55-66-77-88` ❌ Не выиграл
|
||||
- `33-44-55-66-77-88-99` ✅ Выиграл 3 место (AirPods Pro)
|
||||
|
||||
**Клиент получает 2 сообщения:**
|
||||
|
||||
### Сообщение 1:
|
||||
```
|
||||
🎉 Поздравляем! Ваш счет выиграл!
|
||||
|
||||
🎯 Розыгрыш: Новогодний розыгрыш
|
||||
🏆 Место: 1
|
||||
🎁 Приз: iPhone 15
|
||||
💳 Выигрышный счет: 11-22-33-44-55-66-77
|
||||
|
||||
⏰ У вас есть 24 часа для подтверждения!
|
||||
|
||||
Нажмите кнопку ниже, чтобы подтвердить получение приза по этому счету.
|
||||
Если вы не подтвердите в течение 24 часов, приз будет разыгран заново.
|
||||
|
||||
ℹ️ Если у вас несколько выигрышных счетов, подтвердите каждый из них отдельно.
|
||||
|
||||
[✅ Подтвердить счет 11-22-33-44-55-66-77]
|
||||
[📞 Связаться с администратором]
|
||||
```
|
||||
|
||||
### Сообщение 2:
|
||||
```
|
||||
🎉 Поздравляем! Ваш счет выиграл!
|
||||
|
||||
🎯 Розыгрыш: Новогодний розыгрыш
|
||||
🏆 Место: 3
|
||||
🎁 Приз: AirPods Pro
|
||||
💳 Выигрышный счет: 33-44-55-66-77-88-99
|
||||
|
||||
⏰ У вас есть 24 часа для подтверждения!
|
||||
|
||||
[✅ Подтвердить счет 33-44-55-66-77-88-99]
|
||||
[📞 Связаться с администратором]
|
||||
```
|
||||
|
||||
### Действия клиента:
|
||||
|
||||
1. **Нажимает первую кнопку** → Счет `11-22-33-44-55-66-77` подтвержден ✅
|
||||
2. **Нажимает вторую кнопку** → Счет `33-44-55-66-77-88-99` подтвержден ✅
|
||||
|
||||
## 🔒 Безопасность
|
||||
|
||||
### Проверка владения счетом
|
||||
|
||||
При нажатии кнопки подтверждения система проверяет:
|
||||
|
||||
```python
|
||||
# Получаем владельца КОНКРЕТНОГО счета
|
||||
owner = await AccountService.get_account_owner(session, winner.account_number)
|
||||
|
||||
# Проверяем что текущий пользователь - владелец ЭТОГО счета
|
||||
if not owner or owner.telegram_id != callback.from_user.id:
|
||||
await callback.answer(
|
||||
f"❌ Счет {winner.account_number} вам не принадлежит",
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
```
|
||||
|
||||
### Что НЕ может сделать пользователь:
|
||||
|
||||
❌ Подтвердить чужой счет
|
||||
❌ Подтвердить счет, который ему не принадлежит
|
||||
❌ Подтвердить один счет дважды
|
||||
|
||||
### Что может сделать пользователь:
|
||||
|
||||
✅ Подтвердить только свои счета
|
||||
✅ Подтвердить каждый свой выигрышный счет отдельно
|
||||
✅ Видеть номер счета на каждой кнопке
|
||||
|
||||
## 🎊 После подтверждения
|
||||
|
||||
### Сообщение пользователю:
|
||||
|
||||
```
|
||||
✅ Выигрыш успешно подтвержден!
|
||||
|
||||
🎯 Розыгрыш: Новогодний розыгрыш
|
||||
🏆 Место: 1
|
||||
🎁 Приз: iPhone 15
|
||||
💳 Счет: 11-22-33-44-55-66-77
|
||||
|
||||
🎊 Поздравляем! Администратор свяжется с вами
|
||||
для передачи приза в ближайшее время.
|
||||
|
||||
Спасибо за участие!
|
||||
```
|
||||
|
||||
### Уведомление администратору:
|
||||
|
||||
```
|
||||
✅ Победитель подтвердил получение приза!
|
||||
|
||||
🎯 Розыгрыш: Новогодний розыгрыш
|
||||
🏆 Место: 1
|
||||
🎁 Приз: iPhone 15
|
||||
💳 Подтвержденный счет: 11-22-33-44-55-66-77
|
||||
|
||||
👤 Владелец: Иван Петров (@ivan)
|
||||
🎫 Клубная карта: 2223
|
||||
📱 Телефон: +7 900 123-45-67
|
||||
```
|
||||
|
||||
## 📊 База данных
|
||||
|
||||
### Таблица `winners`
|
||||
|
||||
Каждая запись = один выигрышный счет:
|
||||
|
||||
```sql
|
||||
id | lottery_id | account_number | place | prize | is_claimed | claimed_at
|
||||
---|------------|-----------------------|-------|------------|------------|------------------
|
||||
1 | 5 | 11-22-33-44-55-66-77 | 1 | iPhone 15 | TRUE | 2025-11-16 14:00
|
||||
2 | 5 | 33-44-55-66-77-88-99 | 3 | AirPods | TRUE | 2025-11-16 14:05
|
||||
3 | 5 | 55-66-77-88-99-00-11 | 2 | MacBook | FALSE | NULL
|
||||
```
|
||||
|
||||
### Ключевые поля:
|
||||
|
||||
- `account_number` - конкретный выигрышный счет
|
||||
- `is_claimed` - подтвержден ли ЭТОТ счет
|
||||
- `claimed_at` - когда ЭТОТ счет был подтвержден
|
||||
|
||||
## 🔄 Повторный розыгрыш
|
||||
|
||||
Если счет не подтвержден в течение 24 часов:
|
||||
|
||||
1. **Админ проверяет**: `/check_unclaimed 5`
|
||||
```
|
||||
⚠️ Неподтвержденные выигрыши:
|
||||
|
||||
🏆 2 место - MacBook
|
||||
💳 55-66-77-88-99-00-11
|
||||
⏰ Прошло: 26 часов
|
||||
```
|
||||
|
||||
2. **Админ переигрывает**: `/redraw 5`
|
||||
- Удаляется Winner с `account_number = 55-66-77-88-99-00-11`
|
||||
- Выбирается новый случайный счет
|
||||
- Новому владельцу счета отправляется уведомление
|
||||
- Новый владелец получает свои 24 часа
|
||||
|
||||
## 💡 Преимущества подхода
|
||||
|
||||
### ✅ Для пользователей:
|
||||
|
||||
1. **Понятность** - видят конкретный номер счета на кнопке
|
||||
2. **Контроль** - могут подтверждать счета независимо
|
||||
3. **Гибкость** - один счет подтвердил, другой нет (если забыл)
|
||||
|
||||
### ✅ Для администраторов:
|
||||
|
||||
1. **Точность** - знают какой именно счет подтвержден
|
||||
2. **Прозрачность** - видят все действия по каждому счету
|
||||
3. **Справедливость** - переиграть можно конкретный неподтвержденный счет
|
||||
|
||||
### ✅ Для системы:
|
||||
|
||||
1. **Масштабируемость** - неограниченное количество счетов у одного пользователя
|
||||
2. **Независимость** - каждый счет живет своей жизнью
|
||||
3. **Целостность** - нет конфликтов между разными выигрышами
|
||||
|
||||
## 🔍 Примеры использования
|
||||
|
||||
### Пример 1: Один пользователь, два выигрыша
|
||||
|
||||
```
|
||||
Клиент: Иван Петров (КК: 2223)
|
||||
Счета:
|
||||
- 11-22-33-44-55-66-77 → Выиграл 1 место ✅ Подтвержден
|
||||
- 22-33-44-55-66-77-88 → Выиграл 3 место ✅ Подтвержден
|
||||
|
||||
Результат: Иван получит 2 приза
|
||||
```
|
||||
|
||||
### Пример 2: Частичное подтверждение
|
||||
|
||||
```
|
||||
Клиент: Мария Сидорова (КК: 3334)
|
||||
Счета:
|
||||
- 33-44-55-66-77-88-99 → Выиграл 2 место ✅ Подтвержден
|
||||
- 44-55-66-77-88-99-00 → Выиграл 4 место ❌ Не подтвержден (> 24ч)
|
||||
|
||||
Результат:
|
||||
- Мария получит приз за 2 место
|
||||
- 4 место будет переиграно
|
||||
```
|
||||
|
||||
### Пример 3: Полная неявка
|
||||
|
||||
```
|
||||
Клиент: Петр Иванов (КК: 5556)
|
||||
Счета:
|
||||
- 55-66-77-88-99-00-11 → Выиграл 1 место ❌ Не подтвержден
|
||||
- 66-77-88-99-00-11-22 → Выиграл 2 место ❌ Не подтвержден
|
||||
|
||||
Результат: Оба места будут переиграны отдельно
|
||||
```
|
||||
|
||||
## 📝 Технические детали
|
||||
|
||||
### Callback данные
|
||||
|
||||
```python
|
||||
# Формат: confirm_win_{winner_id}
|
||||
callback_data = f"confirm_win_{winner.id}"
|
||||
|
||||
# winner.id - уникальный ID записи в таблице winners
|
||||
# Каждый счет-выигрыш имеет свой winner_id
|
||||
```
|
||||
|
||||
### Проверка при подтверждении
|
||||
|
||||
```python
|
||||
# 1. Получаем winner по ID
|
||||
winner = await session.get(Winner, winner_id)
|
||||
|
||||
# 2. Проверяем что счет принадлежит пользователю
|
||||
owner = await AccountService.get_account_owner(session, winner.account_number)
|
||||
if owner.telegram_id != current_user_id:
|
||||
return "Не ваш счет"
|
||||
|
||||
# 3. Подтверждаем ЭТОТ счет
|
||||
winner.is_claimed = True
|
||||
winner.claimed_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
```
|
||||
|
||||
## 🎓 Выводы
|
||||
|
||||
Подход "счет = выигрыш" обеспечивает:
|
||||
|
||||
- 🎯 **Точность** - подтверждается конкретный счет, а не абстрактный выигрыш
|
||||
- 🔒 **Безопасность** - только владелец счета может подтвердить
|
||||
- 📊 **Масштабируемость** - неограниченное количество счетов и выигрышей
|
||||
- 👥 **Справедливость** - каждый счет обрабатывается независимо
|
||||
- 💡 **Прозрачность** - всегда понятно какой именно счет подтверждается
|
||||
|
||||
---
|
||||
|
||||
## 📞 См. также
|
||||
|
||||
- `AUTO_CONFIRM_SYSTEM.md` - Полная документация системы подтверждения
|
||||
- `REGISTRATION_SYSTEM.md` - Система регистрации счетов
|
||||
- `ADMIN_GUIDE.md` - Руководство администратора
|
||||
318
docs/ADMIN_COMMANDS.md
Normal file
318
docs/ADMIN_COMMANDS.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# Админские команды - Руководство
|
||||
|
||||
## 🎯 Управление счетами
|
||||
|
||||
### `/add_account` - Добавить счет пользователю
|
||||
|
||||
Привязывает счет к зарегистрированному пользователю по клубной карте.
|
||||
|
||||
**Формат:**
|
||||
```
|
||||
/add_account <club_card> <account_number>
|
||||
```
|
||||
|
||||
**Пример:**
|
||||
```
|
||||
/add_account 2223 11-22-33-44-55-66-77
|
||||
```
|
||||
|
||||
**Что происходит:**
|
||||
- Система проверяет существование пользователя с указанной клубной картой
|
||||
- Создает новую запись счета в таблице `accounts`
|
||||
- Отправляет уведомление владельцу о добавлении счета
|
||||
- Счет становится активным и может участвовать в розыгрышах
|
||||
|
||||
**Возможные ошибки:**
|
||||
- "Пользователь с клубной картой X не найден" - пользователь не зарегистрирован
|
||||
- "Счет уже существует" - этот номер счета уже привязан к другому пользователю
|
||||
|
||||
---
|
||||
|
||||
### `/remove_account` - Деактивировать счет
|
||||
|
||||
Делает счет неактивным (не удаляет из БД).
|
||||
|
||||
**Формат:**
|
||||
```
|
||||
/remove_account <account_number>
|
||||
```
|
||||
|
||||
**Пример:**
|
||||
```
|
||||
/remove_account 11-22-33-44-55-66-77
|
||||
```
|
||||
|
||||
**Что происходит:**
|
||||
- Устанавливает флаг `is_active = False`
|
||||
- Счет остается в БД, но не может участвовать в новых розыгрышах
|
||||
- История участия сохраняется
|
||||
|
||||
---
|
||||
|
||||
## 🏆 Управление выигрышами
|
||||
|
||||
### `/verify_winner` - Подтвердить выигрыш
|
||||
|
||||
Подтверждает выигрыш по коду верификации пользователя.
|
||||
|
||||
**Формат:**
|
||||
```
|
||||
/verify_winner <verification_code> <lottery_id>
|
||||
```
|
||||
|
||||
**Пример:**
|
||||
```
|
||||
/verify_winner AB12CD34 1
|
||||
```
|
||||
|
||||
**Процесс верификации:**
|
||||
1. Пользователь сообщает администратору свой код верификации
|
||||
2. Администратор проверяет, что пользователь является победителем указанного розыгрыша
|
||||
3. Система устанавливает флаг `is_claimed = True` для выигрыша
|
||||
4. Отправляется уведомление победителю о подтверждении
|
||||
|
||||
**Что происходит:**
|
||||
- Поиск пользователя по коду верификации
|
||||
- Проверка наличия выигрыша в указанном розыгрыше
|
||||
- Установка флага `is_claimed = True`
|
||||
- Отправка подтверждающего сообщения победителю
|
||||
|
||||
**Возможные ошибки:**
|
||||
- "Розыгрыш не найден" - неверный lottery_id
|
||||
- "Выигрыш не найден" - неверный код или пользователь не победитель
|
||||
- "Выигрыш уже был подтвержден" - повторная попытка подтверждения
|
||||
|
||||
---
|
||||
|
||||
### `/winner_status` - Статус победителей
|
||||
|
||||
Показывает всех победителей розыгрыша и их статус подтверждения.
|
||||
|
||||
**Формат:**
|
||||
```
|
||||
/winner_status <lottery_id>
|
||||
```
|
||||
|
||||
**Пример:**
|
||||
```
|
||||
/winner_status 1
|
||||
```
|
||||
|
||||
**Отображаемая информация:**
|
||||
- 🏆 Место и приз
|
||||
- 👤 Имя и клубная карта победителя
|
||||
- 💳 Номер счета (если участвовал через счет)
|
||||
- ✅ Статус подтверждения (подтвержден / ожидает)
|
||||
- 📨 Статус уведомления (отправлено / нет)
|
||||
|
||||
**Статусы:**
|
||||
- ✅ - Выигрыш подтвержден (`is_claimed = True`)
|
||||
- ⏳ - Ожидает подтверждения (`is_claimed = False`)
|
||||
- 📨 - Уведомление отправлено (`is_notified = True`)
|
||||
- 📭 - Уведомление не отправлено (`is_notified = False`)
|
||||
|
||||
---
|
||||
|
||||
## 👤 Информация о пользователе
|
||||
|
||||
### `/user_info` - Информация о пользователе
|
||||
|
||||
Показывает полную информацию о пользователе по клубной карте.
|
||||
|
||||
**Формат:**
|
||||
```
|
||||
/user_info <club_card>
|
||||
```
|
||||
|
||||
**Пример:**
|
||||
```
|
||||
/user_info 2223
|
||||
```
|
||||
|
||||
**Отображаемая информация:**
|
||||
- 🎫 Клубная карта
|
||||
- 👤 Имя и Telegram username
|
||||
- 📞 Телефон (если указан)
|
||||
- 🔑 Код верификации
|
||||
- 📅 Дата регистрации
|
||||
- 💳 Список всех счетов (активные и неактивные)
|
||||
- 🏆 История выигрышей
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Автоматические уведомления
|
||||
|
||||
При проведении розыгрыша (`/conduct` через кнопку) система автоматически:
|
||||
|
||||
1. **Определяет владельцев счетов-победителей**
|
||||
- Ищет запись в таблице `accounts`
|
||||
- Находит владельца через `owner_id`
|
||||
|
||||
2. **Создает токен верификации**
|
||||
- Генерирует уникальный токен для каждого выигрыша
|
||||
- Устанавливает срок действия 24 часа
|
||||
- Сохраняет в таблице `winner_verifications`
|
||||
|
||||
3. **Отправляет уведомление победителю**
|
||||
```
|
||||
🎉 Поздравляем! Ваш счет выиграл!
|
||||
|
||||
🎯 Розыгрыш: Название розыгрыша
|
||||
🏆 Место: 1
|
||||
🎁 Приз: Приз первого места
|
||||
💳 Счет: 11-22-33-44-55-66-77
|
||||
|
||||
🔑 Ваш код верификации: AB12CD34
|
||||
|
||||
Для получения приза свяжитесь с администратором
|
||||
и предоставьте этот код.
|
||||
```
|
||||
|
||||
4. **Устанавливает флаг уведомления**
|
||||
- `is_notified = True` в таблице `winners`
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Система верификации
|
||||
|
||||
### Как это работает:
|
||||
|
||||
1. **При регистрации пользователя:**
|
||||
- Генерируется уникальный 8-символьный код (например, `AB12CD34`)
|
||||
- Сохраняется в `users.verification_code`
|
||||
- Пользователь может посмотреть свой код через `/my_code`
|
||||
|
||||
2. **При выигрыше:**
|
||||
- Владельцу счета отправляется сообщение с его кодом верификации
|
||||
- Создается токен в таблице `winner_verifications`
|
||||
|
||||
3. **При подтверждении выигрыша:**
|
||||
- Администратор запрашивает код у победителя
|
||||
- Вводит команду `/verify_winner AB12CD34 1`
|
||||
- Система проверяет соответствие кода и наличие выигрыша
|
||||
- Устанавливает `is_claimed = True`
|
||||
|
||||
### Преимущества:
|
||||
|
||||
- 🔒 **Безопасность**: Только владелец знает свой код
|
||||
- ✅ **Проверка**: Невозможно подтвердить чужой выигрыш
|
||||
- 📊 **Отслеживание**: Полная история подтверждений
|
||||
- ⏱️ **Контроль**: Токены имеют срок действия
|
||||
|
||||
---
|
||||
|
||||
## 📊 Типичные сценарии
|
||||
|
||||
### Сценарий 1: Новый пользователь с двумя счетами
|
||||
|
||||
```
|
||||
1. Пользователь: /start → "Зарегистрироваться"
|
||||
2. Пользователь: Вводит клубную карту "2223"
|
||||
3. Пользователь: Вводит телефон или пропускает
|
||||
4. Система: Показывает код верификации "AB12CD34"
|
||||
|
||||
5. Админ: /add_account 2223 11-22-33-44-55-66-77
|
||||
6. Админ: /add_account 2223 88-99-00-11-22-33-44
|
||||
|
||||
7. Пользователь: /my_accounts
|
||||
→ Видит оба счета
|
||||
```
|
||||
|
||||
### Сценарий 2: Проведение розыгрыша и подтверждение
|
||||
|
||||
```
|
||||
1. Админ: Создает розыгрыш через интерфейс
|
||||
2. Счета участвуют автоматически
|
||||
3. Админ: Нажимает "Провести розыгрыш"
|
||||
|
||||
4. Система: Автоматически отправляет уведомления:
|
||||
"🎉 Поздравляем! Ваш счет выиграл!
|
||||
💳 Счет: 11-22-33-44-55-66-77
|
||||
🔑 Ваш код верификации: AB12CD34"
|
||||
|
||||
5. Победитель: Связывается с админом, называет код "AB12CD34"
|
||||
|
||||
6. Админ: /verify_winner AB12CD34 1
|
||||
7. Система: Подтверждает выигрыш, отправляет уведомление победителю
|
||||
```
|
||||
|
||||
### Сценарий 3: Проверка статуса всех победителей
|
||||
|
||||
```
|
||||
1. Админ: /winner_status 1
|
||||
|
||||
Результат:
|
||||
🏆 Победители розыгрыша 'Новогодний розыгрыш':
|
||||
|
||||
✅ 1 место - Главный приз
|
||||
👤 Иван (КК: 2223)
|
||||
💳 11-22-33-44-55-66-77
|
||||
✅ Подтвержден
|
||||
|
||||
⏳ 2 место - Второй приз
|
||||
👤 Петр (КК: 3334)
|
||||
💳 22-33-44-55-66-77-88
|
||||
⏳ Ожидает подтверждения
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Техническая информация
|
||||
|
||||
### Таблицы базы данных:
|
||||
|
||||
**accounts** - Счета пользователей
|
||||
- `account_number` - Номер счета (формат XX-XX-XX-XX-XX-XX-XX)
|
||||
- `owner_id` - ID владельца (FK → users.id)
|
||||
- `is_active` - Активен ли счет
|
||||
- `created_at` - Дата создания
|
||||
|
||||
**winner_verifications** - Токены верификации выигрышей
|
||||
- `winner_id` - ID выигрыша (FK → winners.id)
|
||||
- `verification_token` - Уникальный токен
|
||||
- `is_verified` - Подтвержден ли
|
||||
- `verified_at` - Время подтверждения
|
||||
- `expires_at` - Срок действия (24 часа)
|
||||
|
||||
**winners** - Победители (расширенная)
|
||||
- Добавлены поля: `is_notified`, `is_claimed`
|
||||
|
||||
**users** - Пользователи (расширенная)
|
||||
- Добавлены поля: `club_card_number`, `phone`, `is_registered`, `verification_code`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Полезные команды для пользователей
|
||||
|
||||
- `/start` - Главное меню / регистрация
|
||||
- `/my_code` - Показать свой код верификации
|
||||
- `/my_accounts` - Список моих счетов
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Важные замечания
|
||||
|
||||
1. **Код верификации** - строго конфиденциальная информация
|
||||
2. **Один счет** = один владелец (нельзя передать счет другому)
|
||||
3. **Деактивация счета** - не удаляет историю участия
|
||||
4. **Токены верификации** - действуют 24 часа
|
||||
5. **Уведомления** - отправляются автоматически только если пользователь зарегистрирован
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Решение проблем
|
||||
|
||||
**Пользователь не получил уведомление о выигрыше:**
|
||||
- Проверить `/winner_status <lottery_id>` - должна быть отметка 📨
|
||||
- Возможно, пользователь заблокировал бота
|
||||
- Проверить telegram_id пользователя через `/user_info`
|
||||
|
||||
**Не получается подтвердить выигрыш:**
|
||||
- Проверить код верификации: `/user_info <club_card>`
|
||||
- Убедиться что lottery_id верный
|
||||
- Проверить что выигрыш еще не подтвержден
|
||||
|
||||
**Счет не добавляется:**
|
||||
- Убедиться что пользователь зарегистрирован
|
||||
- Проверить формат номера счета (7 пар цифр через дефис)
|
||||
- Проверить что счет уникален (не добавлен другому пользователю)
|
||||
439
docs/AUTO_CONFIRM_SYSTEM.md
Normal file
439
docs/AUTO_CONFIRM_SYSTEM.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# Система автоматического подтверждения и повторного розыгрыша
|
||||
|
||||
## 🎯 Обзор системы
|
||||
|
||||
Реализована полная система автоматического подтверждения выигрышей с возможностью повторного розыгрыша для неподтвержденных призов.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Как это работает
|
||||
|
||||
### 1. Победитель получает уведомление
|
||||
|
||||
После проведения розыгрыша победителю автоматически отправляется сообщение с **интерактивной кнопкой**:
|
||||
|
||||
```
|
||||
🎉 Поздравляем! Ваш счет выиграл!
|
||||
|
||||
🎯 Розыгрыш: Новогодний розыгрыш
|
||||
🏆 Место: 1
|
||||
🎁 Приз: Главный приз
|
||||
💳 Счет: 11-22-33-44-55-66-77
|
||||
|
||||
⏰ У вас есть 24 часа для подтверждения!
|
||||
|
||||
Нажмите кнопку ниже, чтобы подтвердить получение приза.
|
||||
Если вы не подтвердите в течение 24 часов, приз будет разыгран заново.
|
||||
|
||||
[✅ Подтвердить получение приза]
|
||||
[📞 Связаться с администратором]
|
||||
```
|
||||
|
||||
### 2. Победитель подтверждает выигрыш
|
||||
|
||||
Пользователь нажимает кнопку "✅ Подтвердить получение приза":
|
||||
|
||||
**Что происходит:**
|
||||
- В БД устанавливается `is_claimed = True`
|
||||
- Сохраняется время подтверждения `claimed_at`
|
||||
- Победитель видит подтверждение:
|
||||
|
||||
```
|
||||
✅ Выигрыш успешно подтвержден!
|
||||
|
||||
🎯 Розыгрыш: Новогодний розыгрыш
|
||||
🏆 Место: 1
|
||||
🎁 Приз: Главный приз
|
||||
|
||||
🎊 Поздравляем! Администратор свяжется с вами
|
||||
для передачи приза в ближайшее время.
|
||||
|
||||
Спасибо за участие!
|
||||
```
|
||||
|
||||
**Администраторы получают уведомление:**
|
||||
|
||||
```
|
||||
✅ Победитель подтвердил получение приза!
|
||||
|
||||
🎯 Розыгрыш: Новогодний розыгрыш
|
||||
🏆 Место: 1
|
||||
🎁 Приз: Главный приз
|
||||
👤 Победитель: Иван (@ivan)
|
||||
🎫 Клубная карта: 2223
|
||||
📱 Телефон: +7 900 123-45-67
|
||||
💳 Счет: 11-22-33-44-55-66-77
|
||||
```
|
||||
|
||||
### 3. Если победитель не подтверждает (24 часа)
|
||||
|
||||
Администратор проверяет неподтвержденные выигрыши:
|
||||
|
||||
```
|
||||
/check_unclaimed 1
|
||||
```
|
||||
|
||||
**Ответ бота:**
|
||||
|
||||
```
|
||||
⚠️ Неподтвержденные выигрыши в розыгрыше 'Новогодний розыгрыш':
|
||||
|
||||
🏆 1 место - Главный приз
|
||||
👤 Иван (КК: 2223)
|
||||
💳 11-22-33-44-55-66-77
|
||||
⏰ Прошло: 26 часов
|
||||
|
||||
🏆 3 место - Третий приз
|
||||
👤 Петр (КК: 3334)
|
||||
💳 22-33-44-55-66-77-88
|
||||
⏰ Прошло: 30 часов
|
||||
|
||||
📊 Всего неподтвержденных: 2
|
||||
|
||||
Используйте /redraw 1 для повторного розыгрыша
|
||||
```
|
||||
|
||||
### 4. Повторный розыгрыш
|
||||
|
||||
Администратор запускает переигровку:
|
||||
|
||||
```
|
||||
/redraw 1
|
||||
```
|
||||
|
||||
**Что происходит:**
|
||||
|
||||
1. **Система находит неподтвержденные выигрыши** (старше 24 часов)
|
||||
2. **Получает пул участников** (исключая текущих победителей)
|
||||
3. **Случайно выбирает новых победителей** для каждого неподтвержденного места
|
||||
4. **Удаляет старых победителей** из БД
|
||||
5. **Создает новых победителей**
|
||||
6. **Отправляет уведомления** новым победителям (с кнопкой подтверждения)
|
||||
|
||||
**Результат для администратора:**
|
||||
|
||||
```
|
||||
🔄 Повторный розыгрыш завершен!
|
||||
|
||||
🎯 Розыгрыш: Новогодний розыгрыш
|
||||
📊 Переиграно мест: 2
|
||||
|
||||
🏆 1 место - Главный приз
|
||||
❌ Было: 11-22-33-44-55-66-77
|
||||
✅ Стало: 99-88-77-66-55-44-33
|
||||
|
||||
🏆 3 место - Третий приз
|
||||
❌ Было: 22-33-44-55-66-77-88
|
||||
✅ Стало: 12-34-56-78-90-12-34
|
||||
|
||||
📨 Новым победителям отправлены уведомления
|
||||
```
|
||||
|
||||
**Новые победители получают** то же уведомление с кнопкой подтверждения и 24-часовым лимитом.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Админские команды
|
||||
|
||||
### `/check_unclaimed <lottery_id>`
|
||||
|
||||
Проверить неподтвержденные выигрыши старше 24 часов.
|
||||
|
||||
**Пример:**
|
||||
```
|
||||
/check_unclaimed 1
|
||||
```
|
||||
|
||||
**Показывает:**
|
||||
- Список всех неподтвержденных выигрышей
|
||||
- Информацию о победителях
|
||||
- Сколько времени прошло с момента уведомления
|
||||
|
||||
### `/redraw <lottery_id>`
|
||||
|
||||
Переиграть розыгрыш для неподтвержденных выигрышей.
|
||||
|
||||
**Пример:**
|
||||
```
|
||||
/redraw 1
|
||||
```
|
||||
|
||||
**Требования:**
|
||||
- Должны быть неподтвержденные выигрыши старше 24 часов
|
||||
- Должны быть доступные участники (не победители)
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Изменения в базе данных
|
||||
|
||||
### Таблица `winners`
|
||||
|
||||
Добавлено новое поле:
|
||||
- `claimed_at` (TIMESTAMP) - время подтверждения выигрыша победителем
|
||||
|
||||
### Миграция
|
||||
|
||||
Файл: `migrations/versions/004_add_claimed_at.py`
|
||||
|
||||
Применить:
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Технические детали
|
||||
|
||||
### Обработчик подтверждения
|
||||
|
||||
**Файл:** `main.py`
|
||||
|
||||
**Callback:** `confirm_win_{winner_id}`
|
||||
|
||||
**Функция:** `confirm_winner_response()`
|
||||
|
||||
**Логика:**
|
||||
1. Проверяет существование выигрыша
|
||||
2. Проверяет, что подтверждает владелец
|
||||
3. Устанавливает `is_claimed = True` и `claimed_at = now()`
|
||||
4. Обновляет сообщение победителя
|
||||
5. Уведомляет всех администраторов
|
||||
|
||||
### Система уведомлений
|
||||
|
||||
**Функция:** `notify_winners_async()`
|
||||
|
||||
**Изменения:**
|
||||
- Добавлена кнопка "✅ Подтвердить получение приза"
|
||||
- Добавлено предупреждение о 24-часовом лимите
|
||||
- Добавлена кнопка "📞 Связаться с администратором"
|
||||
|
||||
### Повторный розыгрыш
|
||||
|
||||
**Файл:** `src/handlers/redraw_handlers.py`
|
||||
|
||||
**Команды:**
|
||||
- `check_unclaimed_winners()` - проверка
|
||||
- `redraw_lottery()` - переигровка
|
||||
|
||||
**Алгоритм:**
|
||||
1. Получает всех победителей розыгрыша
|
||||
2. Фильтрует неподтвержденных (is_claimed=False, is_notified=True, >24ч)
|
||||
3. Получает пул участников (исключая победителей)
|
||||
4. Для каждого неподтвержденного:
|
||||
- Выбирает случайного участника
|
||||
- Удаляет старого победителя
|
||||
- Создает нового победителя
|
||||
- Отправляет уведомление
|
||||
|
||||
---
|
||||
|
||||
## 📊 Состояния выигрыша
|
||||
|
||||
### Таймлайн жизни выигрыша:
|
||||
|
||||
```
|
||||
1. Розыгрыш проведен
|
||||
├─ is_notified: False
|
||||
├─ is_claimed: False
|
||||
└─ claimed_at: NULL
|
||||
|
||||
2. Уведомление отправлено
|
||||
├─ is_notified: True
|
||||
├─ is_claimed: False
|
||||
└─ claimed_at: NULL
|
||||
|
||||
3А. Победитель подтвердил (успех)
|
||||
├─ is_notified: True
|
||||
├─ is_claimed: True
|
||||
└─ claimed_at: 2025-11-16 13:00:00
|
||||
|
||||
3Б. Прошло 24 часа (неудача)
|
||||
├─ is_notified: True
|
||||
├─ is_claimed: False
|
||||
├─ claimed_at: NULL
|
||||
└─ ⏰ time_passed > 24h → переигровка
|
||||
|
||||
4. После переигровки (новый победитель)
|
||||
├─ Старый победитель удален
|
||||
├─ Создан новый победитель
|
||||
├─ is_notified: True (после отправки)
|
||||
├─ is_claimed: False
|
||||
└─ claimed_at: NULL → снова 24 часа
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Важные моменты
|
||||
|
||||
### Безопасность
|
||||
|
||||
1. **Проверка владельца**: Только владелец счета может подтвердить выигрыш
|
||||
2. **Повторное подтверждение**: Если выигрыш уже подтвержден, показывается соответствующее сообщение
|
||||
3. **Права администратора**: Только админы могут запускать `/redraw`
|
||||
|
||||
### Ограничения
|
||||
|
||||
1. **24-часовой лимит**: Жестко закодирован, но легко изменить в коде
|
||||
2. **Пул участников**: Если все участники уже победители, переигровка невозможна
|
||||
3. **Уникальность**: Один счет не может выиграть дважды в одном розыгрыше
|
||||
|
||||
### Отказоустойчивость
|
||||
|
||||
1. **Уведомления**: Если не удается отправить - продолжает работу
|
||||
2. **Транзакции**: Все изменения в БД атомарны
|
||||
3. **Логирование**: Все действия записываются в лог
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Типичные сценарии
|
||||
|
||||
### Сценарий 1: Все подтвердили
|
||||
|
||||
```
|
||||
1. Розыгрыш → 3 победителя
|
||||
2. Все 3 нажали кнопку подтверждения
|
||||
3. Админ: /check_unclaimed 1
|
||||
→ "✅ Все победители подтвердили выигрыш"
|
||||
4. Приз передается всем победителям
|
||||
```
|
||||
|
||||
### Сценарий 2: Один не подтвердил
|
||||
|
||||
```
|
||||
1. Розыгрыш → 3 победителя
|
||||
2. 2 подтвердили, 1 игнорирует
|
||||
3. Через 25 часов админ: /check_unclaimed 1
|
||||
→ "⚠️ 1 неподтвержденный выигрыш"
|
||||
4. Админ: /redraw 1
|
||||
5. Система переигрывает 1 место
|
||||
6. Новый победитель получает уведомление
|
||||
7. У нового победителя снова 24 часа
|
||||
```
|
||||
|
||||
### Сценарий 3: Множественная переигровка
|
||||
|
||||
```
|
||||
1. Розыгрыш → 5 победителей
|
||||
2. 3 подтвердили, 2 игнорируют
|
||||
3. Через 25 часов: /redraw 1
|
||||
4. 2 новых победителя выбраны
|
||||
5. Один из новых тоже игнорирует
|
||||
6. Через 25 часов: /redraw 1 снова
|
||||
7. Выбран еще один новый победитель
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Статистика и мониторинг
|
||||
|
||||
### Проверка статуса
|
||||
|
||||
```
|
||||
/winner_status 1
|
||||
```
|
||||
|
||||
Покажет:
|
||||
- ✅ Подтвержденные выигрыши (с is_claimed=True)
|
||||
- ⏳ Ожидающие подтверждения (с is_claimed=False)
|
||||
- 📨 Статус уведомления (is_notified)
|
||||
|
||||
### SQL запросы для админа
|
||||
|
||||
**Найти все неподтвержденные старше 24 часов:**
|
||||
|
||||
```sql
|
||||
SELECT w.*, l.title
|
||||
FROM winners w
|
||||
JOIN lotteries l ON w.lottery_id = l.id
|
||||
WHERE w.is_notified = TRUE
|
||||
AND w.is_claimed = FALSE
|
||||
AND w.created_at < NOW() - INTERVAL '24 hours';
|
||||
```
|
||||
|
||||
**Статистика по подтверждениям:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
lottery_id,
|
||||
COUNT(*) as total_winners,
|
||||
SUM(CASE WHEN is_claimed THEN 1 ELSE 0 END) as confirmed,
|
||||
SUM(CASE WHEN NOT is_claimed THEN 1 ELSE 0 END) as unconfirmed
|
||||
FROM winners
|
||||
WHERE is_notified = TRUE
|
||||
GROUP BY lottery_id;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Использование
|
||||
|
||||
### Для администратора
|
||||
|
||||
1. **После розыгрыша**: Ничего не делать - система отправит уведомления
|
||||
2. **Через 24-30 часов**: Проверить `/check_unclaimed <id>`
|
||||
3. **Если есть неподтвержденные**: Запустить `/redraw <id>`
|
||||
4. **Повторить** при необходимости
|
||||
|
||||
### Для победителя
|
||||
|
||||
1. **Получить уведомление** с кнопкой
|
||||
2. **Нажать "✅ Подтвердить"**
|
||||
3. **Дождаться связи с админом**
|
||||
4. **Получить приз**
|
||||
|
||||
---
|
||||
|
||||
## 💡 Рекомендации
|
||||
|
||||
### Оптимальные настройки
|
||||
|
||||
- **Лимит подтверждения**: 24 часа (можно увеличить до 48ч)
|
||||
- **Частота проверки**: 1-2 раза в день
|
||||
- **Уведомления**: Включить push-уведомления в боте
|
||||
|
||||
### Коммуникация с пользователями
|
||||
|
||||
После розыгрыша отправьте общее сообщение:
|
||||
|
||||
```
|
||||
🎉 Розыгрыш завершен!
|
||||
|
||||
Если вы выиграли, вам придет сообщение с кнопкой подтверждения.
|
||||
|
||||
⏰ У вас будет 24 часа чтобы подтвердить выигрыш!
|
||||
|
||||
Если не подтвердите - приз будет переигран.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔮 Возможные улучшения
|
||||
|
||||
1. **Напоминания**: Отправка напоминания за 2 часа до истечения срока
|
||||
2. **Гибкий лимит**: Разные лимиты для разных типов призов
|
||||
3. **История**: Логирование всех переигровок
|
||||
4. **Статистика**: Процент подтверждений, среднее время подтверждения
|
||||
5. **Автоматизация**: Cron-задача для автоматической переигровки
|
||||
|
||||
---
|
||||
|
||||
## ✅ Преимущества системы
|
||||
|
||||
- 🤖 **Полная автоматизация**: Минимум ручной работы для админа
|
||||
- ⏱️ **Справедливость**: Четкий дедлайн для всех
|
||||
- 🔄 **Эффективность**: Призы не "зависают" у неактивных победителей
|
||||
- 📊 **Прозрачность**: Полная история всех действий
|
||||
- 🛡️ **Безопасность**: Только владелец может подтвердить
|
||||
- 💬 **UX**: Простая кнопка вместо сложной верификации
|
||||
|
||||
---
|
||||
|
||||
## 📞 Поддержка
|
||||
|
||||
Если что-то пошло не так:
|
||||
|
||||
1. Проверьте логи бота
|
||||
2. Проверьте состояние БД (claimed_at, is_notified, is_claimed)
|
||||
3. Используйте `/winner_status <id>` для диагностики
|
||||
4. При критических ошибках - используйте `/verify_winner` для ручного подтверждения
|
||||
72
docs/CLEAR_DATABASE.md
Normal file
72
docs/CLEAR_DATABASE.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Очистка базы данных - Краткое руководство
|
||||
|
||||
## 🎯 Быстрый старт
|
||||
|
||||
```bash
|
||||
make clear-db
|
||||
```
|
||||
|
||||
Система попросит подтверждение, введите `yes`.
|
||||
|
||||
## ✅ Результат
|
||||
|
||||
База данных будет полностью очищена:
|
||||
- ✅ Удалены все пользователи
|
||||
- ✅ Удалены все розыгрыши
|
||||
- ✅ Удалены все счета
|
||||
- ✅ Удалены все участия
|
||||
- ✅ Удалены все победители
|
||||
- ✅ Сброшены счетчики ID (начнутся с 1)
|
||||
|
||||
## 📊 Статистика очистки
|
||||
|
||||
Скрипт покажет сколько строк удалено из каждой таблицы:
|
||||
|
||||
```
|
||||
✅ winner_verifications - удалено 0 строк
|
||||
✅ winners - удалено 2 строк
|
||||
✅ participations - удалено 1 строк
|
||||
✅ accounts - удалено 2 строк
|
||||
✅ lotteries - удалено 1 строк
|
||||
✅ users - удалено 2 строк
|
||||
```
|
||||
|
||||
## ⚠️ ВНИМАНИЕ
|
||||
|
||||
**Данные удаляются БЕЗ ВОЗМОЖНОСТИ ВОССТАНОВЛЕНИЯ!**
|
||||
|
||||
Перед запуском на production всегда делайте бэкап:
|
||||
|
||||
```bash
|
||||
pg_dump -h 192.168.0.102 -U bot_user -d bot_db > backup.sql
|
||||
```
|
||||
|
||||
## 🔧 Альтернативные способы
|
||||
|
||||
### Через Python напрямую
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
python scripts/clear_database.py
|
||||
```
|
||||
|
||||
### Через SQL (только данные, без сброса ID)
|
||||
|
||||
```sql
|
||||
DELETE FROM winner_verifications;
|
||||
DELETE FROM winners;
|
||||
DELETE FROM participations;
|
||||
DELETE FROM accounts;
|
||||
DELETE FROM lotteries;
|
||||
DELETE FROM users;
|
||||
```
|
||||
|
||||
## 📖 Подробная документация
|
||||
|
||||
См. `scripts/README_CLEAR_DB.md` для полной документации.
|
||||
|
||||
## 🆘 Восстановление из бэкапа
|
||||
|
||||
```bash
|
||||
psql -h 192.168.0.102 -U bot_user -d bot_db < backup.sql
|
||||
```
|
||||
152
docs/QUICK_GUIDE.md
Normal file
152
docs/QUICK_GUIDE.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Быстрая шпаргалка - Команды администратора
|
||||
|
||||
## 🚀 Основные команды
|
||||
|
||||
### Добавление счетов
|
||||
|
||||
**Один счет (быстро):**
|
||||
```
|
||||
/add_account 2223 11-22-33-44-55-66-77
|
||||
```
|
||||
|
||||
**Несколько счетов (пакетом):**
|
||||
```
|
||||
/add_account
|
||||
|
||||
2223 11-22-33-44-55-66-77
|
||||
2223 88-99-00-11-22-33-44
|
||||
3334 12-34-56-78-90-12-34
|
||||
```
|
||||
|
||||
### Управление счетами
|
||||
|
||||
```
|
||||
/remove_account 11-22-33-44-55-66-77 # Деактивировать
|
||||
/user_info 2223 # Информация о пользователе
|
||||
```
|
||||
|
||||
### Работа с розыгрышами
|
||||
|
||||
```
|
||||
/winner_status 1 # Статус победителей
|
||||
/verify_winner AB12CD34 1 # Подтвердить выигрыш
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Типичные сценарии
|
||||
|
||||
### Новый клиент (полный цикл)
|
||||
|
||||
1. Клиент регистрируется: `/start` → "Зарегистрироваться"
|
||||
2. Админ добавляет счета:
|
||||
```
|
||||
/add_account 2223 11-22-33-44-55-66-77
|
||||
```
|
||||
3. Выбрать розыгрыш из списка
|
||||
4. Готово!
|
||||
|
||||
### Массовое добавление (10+ счетов)
|
||||
|
||||
```
|
||||
/add_account
|
||||
|
||||
2223 11-22-33-44-55-66-77
|
||||
2223 22-33-44-55-66-77-88
|
||||
3334 33-44-55-66-77-88-99
|
||||
3334 44-55-66-77-88-99-00
|
||||
5556 55-66-77-88-99-00-11
|
||||
```
|
||||
|
||||
→ Выбрать розыгрыш → Готово!
|
||||
|
||||
### Проведение розыгрыша
|
||||
|
||||
1. В боте: "Розыгрыши" → Выбрать → "Провести розыгрыш"
|
||||
2. Победители получат уведомления автоматически
|
||||
3. Проверить: `/winner_status 1`
|
||||
|
||||
### Подтверждение победителя
|
||||
|
||||
1. Победитель сообщает код: `AB12CD34`
|
||||
2. Админ: `/verify_winner AB12CD34 1`
|
||||
3. Система подтверждает и уведомляет победителя
|
||||
|
||||
---
|
||||
|
||||
## 💡 Полезные советы
|
||||
|
||||
- **Формат счета:** `XX-XX-XX-XX-XX-XX-XX` (7 пар цифр)
|
||||
- **Клубная карта:** Любые цифры (например: 2223, 5556)
|
||||
- **Код верификации:** Генерируется автоматически при регистрации
|
||||
- **Отмена операции:** `/cancel` во время ввода данных
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Частые ошибки
|
||||
|
||||
| Ошибка | Причина | Решение |
|
||||
|--------|---------|---------|
|
||||
| "Пользователь не найден" | Клубная карта не зарегистрирована | Попросить зарегистрироваться через /start |
|
||||
| "Счет уже существует" | Номер занят другим пользователем | Проверить номер или деактивировать старый |
|
||||
| "Неверный формат" | Формат счета неправильный | Использовать XX-XX-XX-XX-XX-XX-XX |
|
||||
| Нет розыгрышей | Нет активных розыгрышей | Создать розыгрыш через админ-панель |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Проверка данных
|
||||
|
||||
### Проверить пользователя:
|
||||
```
|
||||
/user_info 2223
|
||||
```
|
||||
|
||||
Увидите:
|
||||
- Все счета пользователя
|
||||
- Код верификации
|
||||
- Историю выигрышей
|
||||
|
||||
### Проверить победителей:
|
||||
```
|
||||
/winner_status 1
|
||||
```
|
||||
|
||||
Увидите:
|
||||
- Список всех победителей
|
||||
- Статус подтверждения (✅/⏳)
|
||||
- Информацию о владельцах
|
||||
|
||||
---
|
||||
|
||||
## 📞 Пользовательские команды
|
||||
|
||||
Клиенты используют:
|
||||
- `/start` - Главное меню / регистрация
|
||||
- `/my_code` - Показать код верификации
|
||||
- `/my_accounts` - Список счетов
|
||||
- "Мой счет" (через меню) - Подробная информация
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Быстрый старт (за 1 минуту)
|
||||
|
||||
```bash
|
||||
# 1. Клиент регистрируется
|
||||
Клиент → /start → "Зарегистрироваться" → Вводит данные
|
||||
|
||||
# 2. Админ добавляет счета
|
||||
/add_account
|
||||
2223 11-22-33-44-55-66-77
|
||||
2223 88-99-00-11-22-33-44
|
||||
|
||||
# 3. Выбрать розыгрыш
|
||||
Нажать "🎯 Новогодний розыгрыш"
|
||||
|
||||
# 4. Провести розыгрыш
|
||||
"Розыгрыши" → Выбрать → "Провести"
|
||||
|
||||
# 5. Подтвердить победителя
|
||||
/verify_winner AB12CD34 1
|
||||
|
||||
✅ Готово!
|
||||
```
|
||||
261
docs/REGISTRATION_SYSTEM.md
Normal file
261
docs/REGISTRATION_SYSTEM.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Система регистрации и верификации выигрышей
|
||||
|
||||
## Архитектура
|
||||
|
||||
### Модели данных
|
||||
|
||||
#### 1. User (Расширенная)
|
||||
- `club_card_number` - номер клубной карты (уникальный идентификатор клиента)
|
||||
- `phone` - телефон для связи
|
||||
- `is_registered` - прошел ли полную регистрацию
|
||||
- `verification_code` - секретный код для подтверждения выигрыша (генерируется автоматически)
|
||||
|
||||
#### 2. Account (Новая)
|
||||
- `account_number` - номер счета в формате XX-XX-XX-XX-XX-XX-XX
|
||||
- `owner_id` - владелец счета (связь с User через club_card_number)
|
||||
- `is_active` - активен ли счет
|
||||
|
||||
#### 3. WinnerVerification (Новая)
|
||||
- `winner_id` - связь с Winner
|
||||
- `verification_token` - токен для подтверждения выигрыша
|
||||
- `is_verified` - подтвержден ли выигрыш
|
||||
- `expires_at` - срок действия токена (24 часа)
|
||||
|
||||
## Процессы
|
||||
|
||||
### 1. Регистрация пользователя
|
||||
|
||||
**Инициатор:** Обычный пользователь через бота
|
||||
|
||||
**Шаги:**
|
||||
1. Пользователь отправляет `/start`
|
||||
2. Бот проверяет `is_registered`
|
||||
3. Если `False` - запрашивает:
|
||||
- Номер клубной карты
|
||||
- Телефон (опционально)
|
||||
4. Генерируется `verification_code` (8-символьный уникальный код)
|
||||
5. Код показывается пользователю: "Ваш код верификации: **AB12CD34**. Сохраните его!"
|
||||
6. `is_registered = True`
|
||||
|
||||
### 2. Создание счета администратором
|
||||
|
||||
**Инициатор:** Администратор
|
||||
|
||||
**Формат команды:**
|
||||
```
|
||||
/add_account 2223 11-22-33-44-55-66-77
|
||||
```
|
||||
|
||||
**Процесс:**
|
||||
1. Админ отправляет команду с номером КК и счетом
|
||||
2. Бот находит пользователя по `club_card_number = 2223`
|
||||
3. Создается запись в таблице `accounts`:
|
||||
```python
|
||||
Account(
|
||||
account_number="11-22-33-44-55-66-77",
|
||||
owner_id=user.id,
|
||||
is_active=True
|
||||
)
|
||||
```
|
||||
4. Пользователю отправляется уведомление:
|
||||
```
|
||||
✅ К вашему профилю добавлен счет:
|
||||
11-22-33-44-55-66-77
|
||||
```
|
||||
|
||||
### 3. Проведение розыгрыша с уведомлением победителей
|
||||
|
||||
**Процесс:**
|
||||
1. Администратор проводит розыгрыш
|
||||
2. Выбирается случайный счет (например, `11-22-33-44-55-66-77`)
|
||||
3. Система ищет владельца счета:
|
||||
```python
|
||||
account = Account.query.filter_by(account_number="11-22-33-44-55-66-77").first()
|
||||
user = account.owner if account else None
|
||||
```
|
||||
4. Создается запись `Winner` и `WinnerVerification`:
|
||||
```python
|
||||
winner = Winner(
|
||||
lottery_id=lottery.id,
|
||||
user_id=user.id if user else None,
|
||||
account_number="11-22-33-44-55-66-77",
|
||||
place=1,
|
||||
prize="iPhone 15"
|
||||
)
|
||||
|
||||
verification = WinnerVerification(
|
||||
winner_id=winner.id,
|
||||
verification_token=generate_token(),
|
||||
expires_at=now + 24hours
|
||||
)
|
||||
```
|
||||
|
||||
5. Если `user` найден - отправляется личное сообщение:
|
||||
```
|
||||
🎉 ПОЗДРАВЛЯЕМ!
|
||||
|
||||
Вы выиграли 1 место в розыгрыше "Новогодний розыгрыш"!
|
||||
🏆 Приз: iPhone 15
|
||||
|
||||
📋 Ваш выигрышный счет: 11-22-33-44-55-66-77
|
||||
|
||||
✅ Для подтверждения выигрыша:
|
||||
1. Свяжитесь с администратором @admin_username
|
||||
2. Сообщите ваш код верификации: AB12CD34
|
||||
3. Администратор подтвердит ваш выигрыш в системе
|
||||
|
||||
⏰ Срок подтверждения: 24 часа
|
||||
```
|
||||
|
||||
### 4. Верификация выигрыша администратором
|
||||
|
||||
**Сценарий:**
|
||||
1. Победитель связывается с админом в личных сообщениях
|
||||
2. Сообщает код верификации: `AB12CD34`
|
||||
3. Админ проверяет код в боте:
|
||||
```
|
||||
/verify_winner AB12CD34 1
|
||||
```
|
||||
где `1` - ID розыгрыша
|
||||
|
||||
4. Система:
|
||||
- Находит пользователя по `verification_code = "AB12CD34"`
|
||||
- Проверяет, что у него есть выигрыш в розыгрыше #1
|
||||
- Помечает `winner.is_claimed = True`
|
||||
- Обновляет `verification.is_verified = True`
|
||||
|
||||
5. Победителю отправляется:
|
||||
```
|
||||
✅ Ваш выигрыш подтвержден!
|
||||
|
||||
Администратор свяжется с вами для получения приза.
|
||||
```
|
||||
|
||||
## Дополнительные механизмы безопасности
|
||||
|
||||
### 1. Двухфакторная верификация (опционально)
|
||||
Можно добавить отправку одноразового кода на телефон победителя.
|
||||
|
||||
### 2. Ограничение по времени
|
||||
Токены верификации действуют 24 часа. После истечения срока требуется повторная генерация.
|
||||
|
||||
### 3. Логирование
|
||||
Все действия с выигрышами логируются:
|
||||
- Создание выигрыша
|
||||
- Отправка уведомления
|
||||
- Подтверждение администратором
|
||||
- Получение приза
|
||||
|
||||
## API для админа
|
||||
|
||||
### Команды бота
|
||||
|
||||
#### `/add_account <club_card> <account_number>`
|
||||
Добавить счет к профилю клиента
|
||||
```
|
||||
/add_account 2223 11-22-33-44-55-66-77
|
||||
```
|
||||
|
||||
#### `/remove_account <account_number>`
|
||||
Деактивировать счет
|
||||
```
|
||||
/remove_account 11-22-33-44-55-66-77
|
||||
```
|
||||
|
||||
#### `/verify_winner <verification_code> <lottery_id>`
|
||||
Подтвердить выигрыш победителя
|
||||
```
|
||||
/verify_winner AB12CD34 1
|
||||
```
|
||||
|
||||
#### `/winner_status <lottery_id>`
|
||||
Показать статус всех победителей розыгрыша
|
||||
```
|
||||
/winner_status 1
|
||||
|
||||
Результаты:
|
||||
1 место - @username (КК: 2223) ✅ Подтвержден
|
||||
2 место - Счет: 33-44-55-66-77-88-99 ⏳ Ожидает подтверждения
|
||||
3 место - @user2 (КК: 4445) ❌ Не подтвержден (истек срок)
|
||||
```
|
||||
|
||||
## Аналитика и отчеты
|
||||
|
||||
### Запросы для аналитики
|
||||
|
||||
```sql
|
||||
-- Победители по клубным картам
|
||||
SELECT
|
||||
u.club_card_number,
|
||||
u.first_name,
|
||||
COUNT(w.id) as total_wins,
|
||||
SUM(CASE WHEN w.is_claimed THEN 1 ELSE 0 END) as claimed_wins
|
||||
FROM users u
|
||||
JOIN winners w ON w.user_id = u.id
|
||||
GROUP BY u.id;
|
||||
|
||||
-- Счета с наибольшим количеством участий
|
||||
SELECT
|
||||
a.account_number,
|
||||
u.club_card_number,
|
||||
COUNT(p.id) as participation_count
|
||||
FROM accounts a
|
||||
JOIN users u ON u.id = a.owner_id
|
||||
JOIN participations p ON p.account_id = a.id
|
||||
GROUP BY a.id, u.id
|
||||
ORDER BY participation_count DESC;
|
||||
|
||||
-- Невостребованные выигрыши
|
||||
SELECT
|
||||
l.title,
|
||||
w.place,
|
||||
w.prize,
|
||||
w.account_number,
|
||||
u.club_card_number
|
||||
FROM winners w
|
||||
JOIN lotteries l ON l.id = w.lottery_id
|
||||
LEFT JOIN users u ON u.id = w.user_id
|
||||
WHERE w.is_claimed = false
|
||||
AND w.created_at < NOW() - INTERVAL '24 hours';
|
||||
```
|
||||
|
||||
## Миграция существующих данных
|
||||
|
||||
Если у вас уже есть данные в старой схеме:
|
||||
|
||||
```python
|
||||
# Миграция старых account_number из users в таблицу accounts
|
||||
async def migrate_accounts():
|
||||
users = await session.execute(select(User).where(User.account_number != None))
|
||||
for user in users.scalars():
|
||||
account = Account(
|
||||
account_number=user.account_number,
|
||||
owner_id=user.id,
|
||||
is_active=True
|
||||
)
|
||||
session.add(account)
|
||||
user.account_number = None # Очищаем старое поле
|
||||
await session.commit()
|
||||
```
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Сценарий 1: Полный цикл выигрыша
|
||||
|
||||
1. Регистрация пользователя (КК: 2223)
|
||||
2. Админ добавляет счет: `/add_account 2223 11-22-33-44-55-66-77`
|
||||
3. Счет участвует в розыгрыше
|
||||
4. Счет выигрывает 1 место
|
||||
5. Пользователю приходит уведомление с кодом
|
||||
6. Пользователь связывается с админом, сообщает код
|
||||
7. Админ подтверждает: `/verify_winner AB12CD34 1`
|
||||
8. Выигрыш получен ✅
|
||||
|
||||
### Сценарий 2: Выигрыш незарегистрированного счета
|
||||
|
||||
1. Админ добавляет счет без владельца
|
||||
2. Счет участвует и выигрывает
|
||||
3. Бот показывает в публичном объявлении: "Счет 11-22-33-44-55-66-77 выиграл!"
|
||||
4. Владелец счета регистрируется в боте
|
||||
5. Админ привязывает счет к владельцу
|
||||
6. Дальше стандартная процедура верификации
|
||||
424
docs/UPDATE_LOG.md
Normal file
424
docs/UPDATE_LOG.md
Normal file
@@ -0,0 +1,424 @@
|
||||
# Обновления системы - 16.11.2025
|
||||
|
||||
## ✅ Исправленные ошибки
|
||||
|
||||
### 1. Ошибка "User object has no attribute 'account_number'"
|
||||
**Проблема:** В старой системе поле `account_number` было в таблице `users`, но после миграции оно перенесено в отдельную таблицу `accounts`.
|
||||
|
||||
**Решение:**
|
||||
- Обновлена функция `show_my_account()` в `main.py`
|
||||
- Теперь использует `AccountService.get_user_accounts()` для получения всех счетов пользователя
|
||||
- Показывает список всех счетов с их статусом (активен/неактивен)
|
||||
|
||||
**Новый функционал в "Мой счет":**
|
||||
```
|
||||
💳 Ваши счета
|
||||
|
||||
🎫 Клубная карта: 2223
|
||||
🔑 Код верификации: AB12CD34
|
||||
|
||||
Счета (2):
|
||||
|
||||
1. 11-22-33-44-55-66-77
|
||||
✅ Активен
|
||||
|
||||
2. 88-99-00-11-22-33-44
|
||||
✅ Активен
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Новый функционал
|
||||
|
||||
### 1. Улучшенная команда `/add_account`
|
||||
|
||||
#### Режим 1: Быстрое добавление одного счета
|
||||
```
|
||||
/add_account 2223 11-22-33-44-55-66-77
|
||||
```
|
||||
|
||||
#### Режим 2: Интерактивное добавление (один или несколько)
|
||||
```
|
||||
/add_account
|
||||
```
|
||||
|
||||
Затем отправьте данные:
|
||||
|
||||
**Один счет:**
|
||||
```
|
||||
2223 11-22-33-44-55-66-77
|
||||
```
|
||||
|
||||
**Несколько счетов (пакетное добавление):**
|
||||
```
|
||||
2223 11-22-33-44-55-66-77
|
||||
2223 88-99-00-11-22-33-44
|
||||
3334 12-34-56-78-90-12-34
|
||||
5556 99-88-77-66-55-44-33
|
||||
```
|
||||
|
||||
**Формат:** `клубная_карта номер_счета` (через пробел, каждый счет с новой строки)
|
||||
|
||||
---
|
||||
|
||||
### 2. Автоматическое добавление счетов в розыгрыш
|
||||
|
||||
После успешного добавления счетов система **автоматически предлагает** добавить их в активный розыгрыш:
|
||||
|
||||
```
|
||||
✅ Счет успешно добавлен!
|
||||
|
||||
🎫 Клубная карта: 2223
|
||||
💳 Счет: 11-22-33-44-55-66-77
|
||||
👤 Владелец: Иван
|
||||
|
||||
📨 Владельцу отправлено уведомление
|
||||
|
||||
➕ Добавить счета в розыгрыш?
|
||||
|
||||
Выберите розыгрыш из списка:
|
||||
[🎯 Новогодний розыгрыш]
|
||||
[🎯 Летний розыгрыш]
|
||||
[❌ Пропустить]
|
||||
```
|
||||
|
||||
**Процесс:**
|
||||
1. Администратор добавляет счета командой `/add_account`
|
||||
2. Система показывает список активных розыгрышей
|
||||
3. Администратор выбирает розыгрыш или пропускает
|
||||
4. Счета автоматически добавляются в выбранный розыгрыш
|
||||
|
||||
**Преимущества:**
|
||||
- ✅ Экономия времени - не нужно вручную добавлять каждый счет
|
||||
- ✅ Меньше ошибок - система проверяет дубликаты
|
||||
- ✅ Удобство - всё в одном процессе
|
||||
- ✅ Гибкость - можно пропустить добавление
|
||||
|
||||
---
|
||||
|
||||
### 3. Пакетное добавление счетов
|
||||
|
||||
**Сценарий использования:**
|
||||
У вас есть список новых клиентов с их счетами. Вместо добавления каждого по отдельности:
|
||||
|
||||
```
|
||||
/add_account
|
||||
|
||||
2223 11-22-33-44-55-66-77
|
||||
2223 88-99-00-11-22-33-44
|
||||
3334 12-34-56-78-90-12-34
|
||||
3334 99-88-77-66-55-44-33
|
||||
5556 11-11-11-11-11-11-11
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
```
|
||||
📊 Результаты добавления счетов
|
||||
|
||||
✅ Успешно добавлено: 5
|
||||
|
||||
• 2223 → 11-22-33-44-55-66-77
|
||||
👤 Иван
|
||||
• 2223 → 88-99-00-11-22-33-44
|
||||
👤 Иван
|
||||
• 3334 → 12-34-56-78-90-12-34
|
||||
👤 Петр
|
||||
• 3334 → 99-88-77-66-55-44-33
|
||||
👤 Петр
|
||||
• 5556 → 11-11-11-11-11-11-11
|
||||
👤 Мария
|
||||
|
||||
➕ Добавить счета в розыгрыш?
|
||||
```
|
||||
|
||||
**Обработка ошибок:**
|
||||
Если какие-то счета невалидны, система покажет:
|
||||
```
|
||||
❌ Ошибки: 2
|
||||
|
||||
• Строка 3 (9999 123-456): Пользователь с клубной картой 9999 не найден
|
||||
• Строка 5 (2223 11-22-33): Неверный формат номера счета
|
||||
```
|
||||
|
||||
Остальные счета будут добавлены успешно.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Обновленный процесс работы
|
||||
|
||||
### Полный цикл: Регистрация → Счета → Розыгрыш
|
||||
|
||||
**Шаг 1: Клиент регистрируется**
|
||||
```
|
||||
Клиент: /start
|
||||
Клиент: Нажимает "Зарегистрироваться"
|
||||
Клиент: Вводит клубную карту "2223"
|
||||
Клиент: Вводит телефон или пропускает
|
||||
Система: Показывает код верификации "AB12CD34"
|
||||
```
|
||||
|
||||
**Шаг 2: Администратор добавляет счета**
|
||||
```
|
||||
Админ: /add_account
|
||||
|
||||
2223 11-22-33-44-55-66-77
|
||||
2223 88-99-00-11-22-33-44
|
||||
|
||||
Система: Добавляет оба счета
|
||||
Система: Отправляет уведомления владельцу
|
||||
Система: Предлагает добавить в розыгрыш
|
||||
```
|
||||
|
||||
**Шаг 3: Администратор выбирает розыгрыш**
|
||||
```
|
||||
Админ: Нажимает "🎯 Новогодний розыгрыш"
|
||||
|
||||
Система: Добавляет оба счета в розыгрыш
|
||||
Система: Показывает результат:
|
||||
✅ Добавлено счетов: 2
|
||||
```
|
||||
|
||||
**Шаг 4: Клиент проверяет свои счета**
|
||||
```
|
||||
Клиент: /start → "Мой счет"
|
||||
|
||||
Видит:
|
||||
💳 Ваши счета
|
||||
🎫 Клубная карта: 2223
|
||||
🔑 Код верификации: AB12CD34
|
||||
|
||||
Счета (2):
|
||||
1. 11-22-33-44-55-66-77 ✅ Активен
|
||||
2. 88-99-00-11-22-33-44 ✅ Активен
|
||||
```
|
||||
|
||||
**Шаг 5: Проведение розыгрыша**
|
||||
```
|
||||
Админ: Нажимает "Провести розыгрыш"
|
||||
|
||||
Система: Выбирает победителей
|
||||
Система: Находит владельцев счетов-победителей
|
||||
Система: Отправляет уведомления с кодами верификации
|
||||
```
|
||||
|
||||
**Шаг 6: Подтверждение выигрыша**
|
||||
```
|
||||
Победитель: Связывается с админом, называет код "AB12CD34"
|
||||
|
||||
Админ: /verify_winner AB12CD34 1
|
||||
|
||||
Система: Подтверждает выигрыш
|
||||
Система: Отправляет уведомление победителю
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Примеры использования
|
||||
|
||||
### Пример 1: Добавление счетов для одного пользователя
|
||||
```
|
||||
/add_account
|
||||
|
||||
2223 11-22-33-44-55-66-77
|
||||
2223 22-33-44-55-66-77-88
|
||||
2223 33-44-55-66-77-88-99
|
||||
```
|
||||
|
||||
Результат: Все 3 счета добавлены пользователю с клубной картой 2223
|
||||
|
||||
### Пример 2: Добавление счетов для разных пользователей
|
||||
```
|
||||
/add_account
|
||||
|
||||
2223 11-22-33-44-55-66-77
|
||||
3334 22-33-44-55-66-77-88
|
||||
5556 33-44-55-66-77-88-99
|
||||
7778 44-55-66-77-88-99-00
|
||||
```
|
||||
|
||||
Результат: Каждый счет добавлен соответствующему пользователю
|
||||
|
||||
### Пример 3: Быстрое добавление с немедленным участием
|
||||
```
|
||||
/add_account 2223 11-22-33-44-55-66-77
|
||||
[Выбрать розыгрыш]
|
||||
[Нажать "🎯 Новогодний розыгрыш"]
|
||||
```
|
||||
|
||||
Результат: Счет добавлен и сразу участвует в розыгрыше
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Технические детали
|
||||
|
||||
### Новая FSM структура в admin_account_handlers.py
|
||||
|
||||
```python
|
||||
class AddAccountStates(StatesGroup):
|
||||
waiting_for_data = State() # Ожидание данных счетов
|
||||
choosing_lottery = State() # Выбор розыгрыша
|
||||
```
|
||||
|
||||
### Новые callback handlers
|
||||
|
||||
- `add_to_lottery_{lottery_id}` - Добавить счета в розыгрыш
|
||||
- `skip_lottery_add` - Пропустить добавление в розыгрыш
|
||||
|
||||
### Обновленные функции
|
||||
|
||||
1. **add_account_command()** - Точка входа, поддерживает оба режима
|
||||
2. **process_single_account()** - Обработка одного счета из команды
|
||||
3. **process_accounts_data()** - Обработка пакета счетов
|
||||
4. **show_lottery_selection()** - Показать выбор розыгрыша
|
||||
5. **add_accounts_to_lottery()** - Добавить счета в выбранный розыгрыш
|
||||
|
||||
### Проверки безопасности
|
||||
|
||||
- ✅ Проверка существования пользователя по клубной карте
|
||||
- ✅ Проверка уникальности номера счета
|
||||
- ✅ Проверка формата номера (XX-XX-XX-XX-XX-XX-XX)
|
||||
- ✅ Проверка дубликатов при добавлении в розыгрыш
|
||||
- ✅ Проверка прав администратора
|
||||
|
||||
---
|
||||
|
||||
## 📊 Сравнение: До и После
|
||||
|
||||
### Старый процесс (до обновления):
|
||||
```
|
||||
1. /add_account 2223 11-22-33-44-55-66-77
|
||||
2. [Ждем подтверждения]
|
||||
3. Вручную находим розыгрыш
|
||||
4. Вручную добавляем счет в розыгрыш
|
||||
5. Повторяем для каждого счета
|
||||
```
|
||||
|
||||
**Время на 10 счетов:** ~5-7 минут
|
||||
|
||||
### Новый процесс (после обновления):
|
||||
```
|
||||
1. /add_account
|
||||
2. Вставляем 10 строк с данными
|
||||
3. Выбираем розыгрыш из списка
|
||||
4. Готово!
|
||||
```
|
||||
|
||||
**Время на 10 счетов:** ~30 секунд
|
||||
|
||||
**Экономия времени: 90%!**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Отладка и решение проблем
|
||||
|
||||
### Проблема: Не показываются розыгрыши после добавления счетов
|
||||
|
||||
**Причина:** Нет активных розыгрышей
|
||||
|
||||
**Решение:** Создайте активный розыгрыш через админ-панель
|
||||
|
||||
### Проблема: Ошибка "Пользователь не найден"
|
||||
|
||||
**Причина:** Клубная карта не зарегистрирована
|
||||
|
||||
**Решение:**
|
||||
1. Попросите пользователя зарегистрироваться через /start
|
||||
2. Или проверьте правильность клубной карты через /user_info
|
||||
|
||||
### Проблема: "Счет уже существует"
|
||||
|
||||
**Причина:** Этот номер счета уже привязан к другому пользователю
|
||||
|
||||
**Решение:**
|
||||
1. Проверьте номер счета
|
||||
2. Если ошибка - деактивируйте старый: /remove_account <номер>
|
||||
3. Добавьте заново
|
||||
|
||||
### Проблема: Счет не добавился в розыгрыш
|
||||
|
||||
**Причина:** Счет уже участвует в этом розыгрыше
|
||||
|
||||
**Решение:** Это нормально, система предотвращает дубликаты
|
||||
|
||||
---
|
||||
|
||||
## 📚 Команды для администратора (обновленный список)
|
||||
|
||||
### Управление счетами:
|
||||
- `/add_account` - Добавить счета (интерактивно или пакетом)
|
||||
- `/add_account <club_card> <account>` - Быстрое добавление одного счета
|
||||
- `/remove_account <account>` - Деактивировать счет
|
||||
- `/user_info <club_card>` - Информация о пользователе
|
||||
|
||||
### Управление розыгрышами:
|
||||
- Создание через интерфейс (кнопка "Создать розыгрыш")
|
||||
- Проведение через интерфейс (кнопка "Провести розыгрыш")
|
||||
- `/winner_status <lottery_id>` - Статус победителей
|
||||
|
||||
### Верификация:
|
||||
- `/verify_winner <code> <lottery_id>` - Подтвердить выигрыш
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Рекомендации по использованию
|
||||
|
||||
### Для массового добавления счетов:
|
||||
|
||||
1. **Подготовьте данные в текстовом файле**
|
||||
```
|
||||
2223 11-22-33-44-55-66-77
|
||||
2223 88-99-00-11-22-33-44
|
||||
3334 12-34-56-78-90-12-34
|
||||
...
|
||||
```
|
||||
|
||||
2. **Скопируйте и вставьте в бота**
|
||||
```
|
||||
/add_account
|
||||
[Вставить все строки]
|
||||
```
|
||||
|
||||
3. **Выберите розыгрыш**
|
||||
|
||||
4. **Проверьте результаты**
|
||||
|
||||
### Для одиночных счетов:
|
||||
|
||||
Используйте быстрый режим:
|
||||
```
|
||||
/add_account 2223 11-22-33-44-55-66-77
|
||||
```
|
||||
|
||||
### Для новых пользователей:
|
||||
|
||||
1. Сначала попросите зарегистрироваться
|
||||
2. Затем добавьте счета
|
||||
3. Счета автоматически будут доступны в розыгрышах
|
||||
|
||||
---
|
||||
|
||||
## ✅ Чек-лист для администратора
|
||||
|
||||
При добавлении новых пользователей:
|
||||
|
||||
- [ ] Пользователь прошел регистрацию (/start → Зарегистрироваться)
|
||||
- [ ] Пользователь получил код верификации
|
||||
- [ ] Добавлены все счета через /add_account
|
||||
- [ ] Счета добавлены в активный розыгрыш
|
||||
- [ ] Пользователю отправлены уведомления
|
||||
- [ ] Проверено через /user_info <club_card>
|
||||
|
||||
При проведении розыгрыша:
|
||||
|
||||
- [ ] Все счета добавлены и активны
|
||||
- [ ] Розыгрыш настроен (призы, описание)
|
||||
- [ ] Нажата кнопка "Провести розыгрыш"
|
||||
- [ ] Победители получили уведомления
|
||||
- [ ] Проверен статус через /winner_status <id>
|
||||
|
||||
При подтверждении выигрышей:
|
||||
|
||||
- [ ] Победитель сообщил код верификации
|
||||
- [ ] Код проверен командой /verify_winner
|
||||
- [ ] Победитель получил подтверждение
|
||||
- [ ] Приз передан победителю
|
||||
370
main.py
370
main.py
@@ -16,8 +16,12 @@ import sys
|
||||
from src.core.config import BOT_TOKEN, ADMIN_IDS
|
||||
from src.core.database import async_session_maker, init_db
|
||||
from src.core.services import UserService, LotteryService, ParticipationService
|
||||
from src.core.models import User
|
||||
from src.handlers.admin_panel import admin_router
|
||||
from src.handlers.account_handlers import account_router
|
||||
from src.handlers.registration_handlers import router as registration_router
|
||||
from src.handlers.admin_account_handlers import router as admin_account_router
|
||||
from src.handlers.redraw_handlers import router as redraw_router
|
||||
from src.utils.async_decorators import (
|
||||
async_user_action, admin_async_action, db_operation,
|
||||
TaskManagerMiddleware, shutdown_task_manager,
|
||||
@@ -65,16 +69,19 @@ def is_admin(user_id: int) -> bool:
|
||||
def get_main_keyboard(is_admin_user: bool = False) -> InlineKeyboardMarkup:
|
||||
"""Главная клавиатура"""
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="list_lotteries")],
|
||||
[InlineKeyboardButton(text="📝 Мои участия", callback_data="my_participations")],
|
||||
[InlineKeyboardButton(text="💳 Мой счёт", callback_data="my_account")]
|
||||
[InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="list_lotteries")]
|
||||
]
|
||||
|
||||
if not is_admin_user:
|
||||
buttons.extend([
|
||||
[InlineKeyboardButton(text="📝 Мои участия", callback_data="my_participations")],
|
||||
[InlineKeyboardButton(text="💳 Мой счёт", callback_data="my_account")]
|
||||
])
|
||||
|
||||
if is_admin_user:
|
||||
buttons.extend([
|
||||
[InlineKeyboardButton(text="🔧 Админ-панель", callback_data="admin_panel")],
|
||||
[InlineKeyboardButton(text="➕ Создать розыгрыш", callback_data="create_lottery")],
|
||||
[InlineKeyboardButton(text="👑 Установить победителя", callback_data="set_winner")],
|
||||
[InlineKeyboardButton(text="📊 Статистика задач", callback_data="task_stats")]
|
||||
])
|
||||
|
||||
@@ -96,11 +103,29 @@ async def cmd_start(message: Message):
|
||||
# Устанавливаем права администратора, если пользователь в списке
|
||||
if message.from_user.id in ADMIN_IDS:
|
||||
await UserService.set_admin(session, message.from_user.id, True)
|
||||
|
||||
is_registered = user.is_registered
|
||||
|
||||
is_admin_user = is_admin(message.from_user.id)
|
||||
|
||||
welcome_text = f"Добро пожаловать, {message.from_user.first_name}! 🎉\n\n"
|
||||
welcome_text += "Это бот для проведения розыгрышей.\n\n"
|
||||
|
||||
# Для обычных пользователей - проверяем регистрацию
|
||||
if not is_admin_user and not is_registered:
|
||||
welcome_text += "⚠️ Для участия в розыгрышах необходимо пройти регистрацию.\n\n"
|
||||
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="📝 Зарегистрироваться", callback_data="start_registration")],
|
||||
[InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="list_lotteries")]
|
||||
]
|
||||
|
||||
await message.answer(
|
||||
welcome_text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
)
|
||||
return
|
||||
|
||||
welcome_text += "Выберите действие из меню ниже:"
|
||||
|
||||
if is_admin_user:
|
||||
@@ -196,9 +221,18 @@ async def show_lottery_details(callback: CallbackQuery):
|
||||
if winners:
|
||||
text += "\n\n🏆 Победители:\n"
|
||||
for winner in winners:
|
||||
# Используем новую систему отображения
|
||||
winner_display = format_winner_display(winner.user, lottery, show_sensitive_data=False)
|
||||
text += f"{winner.place}. {winner_display}\n"
|
||||
# Безопасное отображение победителя
|
||||
if winner.user:
|
||||
if winner.user.username:
|
||||
winner_display = f"@{winner.user.username}"
|
||||
else:
|
||||
winner_display = f"{winner.user.first_name}"
|
||||
elif winner.account_number:
|
||||
winner_display = f"Счет: {winner.account_number}"
|
||||
else:
|
||||
winner_display = "Участник"
|
||||
|
||||
text += f"{winner.place}. {winner_display} - {winner.prize}\n"
|
||||
else:
|
||||
text += f"\n🟢 Статус: {'Активен' if lottery.is_active else 'Неактивен'}"
|
||||
if is_participating:
|
||||
@@ -241,7 +275,8 @@ async def join_lottery(callback: CallbackQuery):
|
||||
await callback.answer("Ошибка получения данных пользователя", show_alert=True)
|
||||
return
|
||||
|
||||
success = await LotteryService.add_participant(session, lottery_id, user.id)
|
||||
# Используем правильный метод ParticipationService
|
||||
success = await ParticipationService.add_participant(session, lottery_id, user.id)
|
||||
|
||||
if success:
|
||||
await callback.answer("✅ Вы успешно присоединились к розыгрышу!", show_alert=True)
|
||||
@@ -252,6 +287,237 @@ async def join_lottery(callback: CallbackQuery):
|
||||
await show_lottery_details(callback)
|
||||
|
||||
|
||||
async def notify_winners_async(bot: Bot, lottery_id: int, results: dict):
|
||||
"""
|
||||
Асинхронно отправить уведомления победителям с кнопкой подтверждения
|
||||
Вызывается после проведения розыгрыша
|
||||
"""
|
||||
async with async_session_maker() as session:
|
||||
from src.core.registration_services import AccountService, WinnerNotificationService
|
||||
from src.core.models import Winner
|
||||
from sqlalchemy import select
|
||||
|
||||
# Получаем информацию о розыгрыше
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
return
|
||||
|
||||
# Получаем всех победителей из БД
|
||||
winners_result = await session.execute(
|
||||
select(Winner).where(Winner.lottery_id == lottery_id)
|
||||
)
|
||||
winners = winners_result.scalars().all()
|
||||
|
||||
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}")
|
||||
|
||||
# Если победитель - обычный пользователь (старая система)
|
||||
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"⏰ **У вас есть 24 часа для подтверждения!**\n\n"
|
||||
f"Нажмите кнопку ниже, чтобы подтвердить получение приза."
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="✅ Подтвердить получение приза",
|
||||
callback_data=f"confirm_win_{winner.id}"
|
||||
)]
|
||||
])
|
||||
|
||||
await bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
winner.is_notified = True
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Отправлено уведомление победителю {user.telegram_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомления победителю: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_win_"))
|
||||
async def confirm_winner_response(callback: CallbackQuery):
|
||||
"""Обработка подтверждения выигрыша победителем"""
|
||||
winner_id = int(callback.data.split("_")[2])
|
||||
|
||||
async with async_session_maker() as session:
|
||||
from src.core.models import Winner
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
# Получаем выигрыш с загрузкой связанного розыгрыша
|
||||
winner_result = await session.execute(
|
||||
select(Winner)
|
||||
.options(joinedload(Winner.lottery))
|
||||
.where(Winner.id == winner_id)
|
||||
)
|
||||
winner = winner_result.scalar_one_or_none()
|
||||
|
||||
if not winner:
|
||||
await callback.answer("❌ Выигрыш не найден", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем, не подтвержден ли уже этот конкретный счет
|
||||
if winner.is_claimed:
|
||||
await callback.message.edit_text(
|
||||
"✅ **Выигрыш этого счета уже подтвержден!**\n\n"
|
||||
f"🎯 Розыгрыш: {winner.lottery.title}\n"
|
||||
f"🏆 Место: {winner.place}\n"
|
||||
f"🎁 Приз: {winner.prize}\n"
|
||||
f"💳 Счет: {winner.account_number}\n\n"
|
||||
"Администратор свяжется с вами для передачи приза.",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
return
|
||||
|
||||
# Проверяем, что подтверждает владелец именно ЭТОГО счета
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
|
||||
if winner.account_number:
|
||||
# Проверяем что счет принадлежит текущему пользователю
|
||||
from src.core.registration_services import AccountService
|
||||
owner = await AccountService.get_account_owner(session, winner.account_number)
|
||||
|
||||
if not owner or owner.telegram_id != callback.from_user.id:
|
||||
await callback.answer(
|
||||
f"❌ Счет {winner.account_number} вам не принадлежит",
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
elif winner.user_id:
|
||||
# Старая логика для выигрышей без счета
|
||||
if not user or user.id != winner.user_id:
|
||||
await callback.answer("❌ Это не ваш выигрыш", show_alert=True)
|
||||
return
|
||||
|
||||
# Подтверждаем выигрыш ЭТОГО конкретного счета
|
||||
from datetime import datetime, timezone
|
||||
winner.is_claimed = True
|
||||
winner.claimed_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
# Обновляем сообщение с указанием счета
|
||||
confirmation_text = (
|
||||
"✅ **Выигрыш успешно подтвержден!**\n\n"
|
||||
f"🎯 Розыгрыш: {winner.lottery.title}\n"
|
||||
f"🏆 Место: {winner.place}\n"
|
||||
f"🎁 Приз: {winner.prize}\n"
|
||||
)
|
||||
|
||||
if winner.account_number:
|
||||
confirmation_text += f"💳 Счет: {winner.account_number}\n"
|
||||
|
||||
confirmation_text += (
|
||||
"\n🎊 Поздравляем! Администратор свяжется с вами "
|
||||
"для передачи приза в ближайшее время.\n\n"
|
||||
"Спасибо за участие!"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
confirmation_text,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
# Уведомляем администраторов о подтверждении конкретного счета
|
||||
for admin_id in ADMIN_IDS:
|
||||
try:
|
||||
admin_msg = (
|
||||
f"✅ **Победитель подтвердил получение приза!**\n\n"
|
||||
f"🎯 Розыгрыш: {winner.lottery.title}\n"
|
||||
f"🏆 Место: {winner.place}\n"
|
||||
f"🎁 Приз: {winner.prize}\n"
|
||||
)
|
||||
|
||||
# Обязательно показываем счет
|
||||
if winner.account_number:
|
||||
admin_msg += f"<EFBFBD> **Подтвержденный счет: {winner.account_number}**\n\n"
|
||||
|
||||
if user:
|
||||
admin_msg += f"👤 Владелец: {user.first_name}"
|
||||
if user.username:
|
||||
admin_msg += f" (@{user.username})"
|
||||
admin_msg += f"\n🎫 Клубная карта: {user.club_card_number}\n"
|
||||
if user.phone:
|
||||
admin_msg += f"📱 Телефон: {user.phone}\n"
|
||||
|
||||
await callback.bot.send_message(admin_id, admin_msg, parse_mode="Markdown")
|
||||
except:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
f"Победитель {callback.from_user.id} подтвердил выигрыш {winner_id} "
|
||||
f"(счет: {winner.account_number})"
|
||||
)
|
||||
|
||||
await callback.answer("✅ Выигрыш подтвержден!", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("conduct_"))
|
||||
async def conduct_lottery(callback: CallbackQuery):
|
||||
"""Провести розыгрыш"""
|
||||
@@ -262,6 +528,11 @@ async def conduct_lottery(callback: CallbackQuery):
|
||||
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
|
||||
|
||||
results = await LotteryService.conduct_draw(session, lottery_id)
|
||||
|
||||
if not results:
|
||||
@@ -271,14 +542,26 @@ async def conduct_lottery(callback: CallbackQuery):
|
||||
text = "🎉 Розыгрыш завершен!\n\n🏆 Победители:\n\n"
|
||||
|
||||
for place, winner_info in results.items():
|
||||
user = winner_info['user']
|
||||
user_obj = winner_info['user']
|
||||
prize = winner_info['prize']
|
||||
|
||||
# Используем новую систему отображения
|
||||
winner_display = format_winner_display(user, lottery, show_sensitive_data=False)
|
||||
# Безопасное отображение победителя
|
||||
if hasattr(user_obj, 'username') and user_obj.username:
|
||||
winner_display = f"@{user_obj.username}"
|
||||
elif hasattr(user_obj, 'first_name'):
|
||||
winner_display = f"{user_obj.first_name}"
|
||||
elif hasattr(user_obj, 'account_number'):
|
||||
winner_display = f"Счет: {user_obj.account_number}"
|
||||
else:
|
||||
winner_display = "Участник"
|
||||
|
||||
text += f"{place}. {winner_display}\n"
|
||||
text += f" 🎁 {prize}\n\n"
|
||||
|
||||
# Отправляем уведомления победителям асинхронно
|
||||
asyncio.create_task(notify_winners_async(callback.bot, lottery_id, results))
|
||||
text += "📨 Уведомления отправляются победителям...\n"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -339,6 +622,11 @@ async def process_lottery_prizes(message: Message, state: FSMContext):
|
||||
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("❌ Ошибка получения данных пользователя")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
lottery = await LotteryService.create_lottery(
|
||||
session,
|
||||
@@ -529,7 +817,7 @@ async def show_my_participations(callback: CallbackQuery):
|
||||
@router.callback_query(F.data == "my_account")
|
||||
@db_operation()
|
||||
async def show_my_account(callback: CallbackQuery):
|
||||
"""Показать информацию о счёте пользователя"""
|
||||
"""Показать информацию о счетах пользователя"""
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
|
||||
@@ -537,27 +825,44 @@ async def show_my_account(callback: CallbackQuery):
|
||||
await callback.answer("Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
text = "💳 **Ваш клиентский счёт**\n\n"
|
||||
# Проверяем регистрацию
|
||||
if not user.is_registered:
|
||||
text = "❌ **Вы не зарегистрированы**\n\n"
|
||||
text += "Пройдите регистрацию для доступа к счетам"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📝 Зарегистрироваться", callback_data="start_registration")],
|
||||
[InlineKeyboardButton(text="🔙 Главное меню", callback_data="back_to_main")]
|
||||
]),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
return
|
||||
|
||||
if user.account_number:
|
||||
# Показываем маскированный номер для безопасности
|
||||
from src.utils.account_utils import mask_account_number
|
||||
masked = mask_account_number(user.account_number, show_last_digits=6)
|
||||
text += f"📋 Номер счёта: `{masked}`\n"
|
||||
text += f"✅ Статус: Активен\n\n"
|
||||
text += "ℹ️ Счёт используется для идентификации в розыгрышах"
|
||||
# Получаем счета пользователя
|
||||
from src.core.registration_services import AccountService
|
||||
accounts = await AccountService.get_user_accounts(session, user.id)
|
||||
|
||||
text = "💳 **Ваши счета**\n\n"
|
||||
|
||||
if accounts:
|
||||
text += f"🎫 Клубная карта: `{user.club_card_number}`\n"
|
||||
text += f"<EFBFBD> Код верификации: `{user.verification_code}`\n\n"
|
||||
text += f"**Счета ({len(accounts)}):**\n\n"
|
||||
|
||||
for i, acc in enumerate(accounts, 1):
|
||||
status = "✅ Активен" if acc.is_active else "❌ Неактивен"
|
||||
text += f"{i}. `{acc.account_number}`\n"
|
||||
text += f" {status}\n\n"
|
||||
|
||||
text += "ℹ️ Счета используются для участия в розыгрышах"
|
||||
else:
|
||||
text += "❌ Счёт не привязан\n\n"
|
||||
text += "Привяжите счёт для участия в розыгрышах"
|
||||
text += f"🎫 Клубная карта: `{user.club_card_number}`\n\n"
|
||||
text += "❌ У вас нет счетов\n\n"
|
||||
text += "Обратитесь к администратору для добавления счетов"
|
||||
|
||||
buttons = []
|
||||
|
||||
if user.account_number:
|
||||
buttons.append([InlineKeyboardButton(text="🔄 Изменить счёт", callback_data="change_account")])
|
||||
else:
|
||||
buttons.append([InlineKeyboardButton(text="➕ Привязать счёт", callback_data="add_account")])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔙 Главное меню", callback_data="back_to_main")])
|
||||
buttons = [[InlineKeyboardButton(text="🔙 Главное меню", callback_data="back_to_main")]]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
@@ -700,7 +1005,10 @@ async def main():
|
||||
await set_commands()
|
||||
|
||||
# Подключение роутеров
|
||||
dp.include_router(account_router) # Роутер для работы со счетами (приоритетный)
|
||||
dp.include_router(registration_router) # Роутер регистрации (первый)
|
||||
dp.include_router(admin_account_router) # Роутер админских команд для счетов
|
||||
dp.include_router(redraw_router) # Роутер повторного розыгрыша
|
||||
dp.include_router(account_router) # Роутер для работы со счетами
|
||||
dp.include_router(router)
|
||||
dp.include_router(admin_router)
|
||||
|
||||
|
||||
108
migrations/versions/003_add_registration_and_accounts.py
Normal file
108
migrations/versions/003_add_registration_and_accounts.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Add registration and account management
|
||||
|
||||
Revision ID: 003
|
||||
Revises: init
|
||||
Create Date: 2025-11-16 13:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '003'
|
||||
down_revision = 'init'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Добавляем новые поля в users
|
||||
op.add_column('users', sa.Column('phone', sa.String(20), nullable=True))
|
||||
op.add_column('users', sa.Column('club_card_number', sa.String(50), nullable=True))
|
||||
op.add_column('users', sa.Column('is_registered', sa.Boolean(), server_default='false', nullable=False))
|
||||
op.add_column('users', sa.Column('verification_code', sa.String(10), nullable=True))
|
||||
|
||||
# Создаем индексы для users
|
||||
op.create_index('ix_users_club_card_number', 'users', ['club_card_number'], unique=True)
|
||||
op.create_index('ix_users_verification_code', 'users', ['verification_code'], unique=True)
|
||||
|
||||
# Удаляем старое поле account_number из users (оно теперь в отдельной таблице)
|
||||
# Сначала удаляем индекс
|
||||
op.drop_index('ix_users_account_number', table_name='users')
|
||||
op.drop_column('users', 'account_number')
|
||||
|
||||
# Создаем таблицу accounts
|
||||
op.create_table(
|
||||
'accounts',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('account_number', sa.String(20), nullable=False),
|
||||
sa.Column('owner_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_accounts_account_number', 'accounts', ['account_number'], unique=True)
|
||||
op.create_index('ix_accounts_owner_id', 'accounts', ['owner_id'])
|
||||
|
||||
# Добавляем поле account_id в participations
|
||||
op.add_column('participations', sa.Column('account_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
'fk_participations_account_id',
|
||||
'participations', 'accounts',
|
||||
['account_id'], ['id'],
|
||||
ondelete='SET NULL'
|
||||
)
|
||||
|
||||
# Добавляем поля в winners для отслеживания статуса
|
||||
op.add_column('winners', sa.Column('is_notified', sa.Boolean(), server_default='false', nullable=False))
|
||||
op.add_column('winners', sa.Column('is_claimed', sa.Boolean(), server_default='false', nullable=False))
|
||||
|
||||
# Создаем таблицу winner_verifications
|
||||
op.create_table(
|
||||
'winner_verifications',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('winner_id', sa.Integer(), nullable=False),
|
||||
sa.Column('verification_token', sa.String(32), nullable=False),
|
||||
sa.Column('is_verified', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('verified_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['winner_id'], ['winners.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_winner_verifications_winner_id', 'winner_verifications', ['winner_id'], unique=True)
|
||||
op.create_index('ix_winner_verifications_token', 'winner_verifications', ['verification_token'], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Удаляем winner_verifications
|
||||
op.drop_index('ix_winner_verifications_token', table_name='winner_verifications')
|
||||
op.drop_index('ix_winner_verifications_winner_id', table_name='winner_verifications')
|
||||
op.drop_table('winner_verifications')
|
||||
|
||||
# Удаляем новые поля из winners
|
||||
op.drop_column('winners', 'is_claimed')
|
||||
op.drop_column('winners', 'is_notified')
|
||||
|
||||
# Удаляем account_id из participations
|
||||
op.drop_constraint('fk_participations_account_id', 'participations', type_='foreignkey')
|
||||
op.drop_column('participations', 'account_id')
|
||||
|
||||
# Удаляем таблицу accounts
|
||||
op.drop_index('ix_accounts_owner_id', table_name='accounts')
|
||||
op.drop_index('ix_accounts_account_number', table_name='accounts')
|
||||
op.drop_table('accounts')
|
||||
|
||||
# Возвращаем account_number в users
|
||||
op.add_column('users', sa.Column('account_number', sa.String(20), nullable=True))
|
||||
op.create_index('ix_users_account_number', 'users', ['account_number'], unique=True)
|
||||
|
||||
# Удаляем новые поля из users
|
||||
op.drop_index('ix_users_verification_code', table_name='users')
|
||||
op.drop_index('ix_users_club_card_number', table_name='users')
|
||||
op.drop_column('users', 'verification_code')
|
||||
op.drop_column('users', 'is_registered')
|
||||
op.drop_column('users', 'club_card_number')
|
||||
op.drop_column('users', 'phone')
|
||||
26
migrations/versions/004_add_claimed_at.py
Normal file
26
migrations/versions/004_add_claimed_at.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Add claimed_at field to winners
|
||||
|
||||
Revision ID: 004
|
||||
Revises: 003
|
||||
Create Date: 2025-11-16
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '004'
|
||||
down_revision = '003'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Add claimed_at timestamp to winners table"""
|
||||
op.add_column('winners', sa.Column('claimed_at', sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Remove claimed_at field"""
|
||||
op.drop_column('winners', 'claimed_at')
|
||||
227
scripts/README_CLEAR_DB.md
Normal file
227
scripts/README_CLEAR_DB.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# 🗑️ Скрипт очистки базы данных
|
||||
|
||||
## Описание
|
||||
|
||||
`clear_database.py` - скрипт для полной очистки всех данных из базы данных бота.
|
||||
|
||||
## ⚠️ ВНИМАНИЕ!
|
||||
|
||||
**Этот скрипт удаляет ВСЕ данные без возможности восстановления:**
|
||||
- Всех пользователей
|
||||
- Все розыгрыши (активные и завершенные)
|
||||
- Все счета участников
|
||||
- Все записи об участии
|
||||
- Всех победителей
|
||||
- Все записи верификации
|
||||
|
||||
## Использование
|
||||
|
||||
### Через Makefile (рекомендуется)
|
||||
|
||||
```bash
|
||||
make clear-db
|
||||
```
|
||||
|
||||
Система попросит подтверждение:
|
||||
```
|
||||
⚠️ ВНИМАНИЕ! Это удалит ВСЕ данные из базы данных!
|
||||
- Все пользователи
|
||||
- Все розыгрыши
|
||||
- Все счета
|
||||
- Все участия
|
||||
- Всех победителей
|
||||
|
||||
Вы уверены? Введите 'yes' для подтверждения:
|
||||
```
|
||||
|
||||
### Напрямую через Python
|
||||
|
||||
```bash
|
||||
cd /home/trevor/new_lottery_bot
|
||||
source .venv/bin/activate
|
||||
python scripts/clear_database.py
|
||||
```
|
||||
|
||||
## Что делает скрипт
|
||||
|
||||
1. **Подключается к базе данных** используя `DATABASE_URL` из `.env`
|
||||
|
||||
2. **Удаляет данные в правильном порядке:**
|
||||
- `winner_verifications` - записи верификации победителей
|
||||
- `winners` - записи о победителях
|
||||
- `participations` - записи об участии в розыгрышах
|
||||
- `accounts` - счета участников
|
||||
- `lotteries` - розыгрыши
|
||||
- `users` - пользователи
|
||||
|
||||
3. **Сбрасывает счетчики ID (sequences):**
|
||||
- `users_id_seq` → 1
|
||||
- `lotteries_id_seq` → 1
|
||||
- `accounts_id_seq` → 1
|
||||
- `participations_id_seq` → 1
|
||||
- `winners_id_seq` → 1
|
||||
- `winner_verifications_id_seq` → 1
|
||||
|
||||
4. **Фиксирует транзакцию** (COMMIT)
|
||||
|
||||
5. **Закрывает соединение**
|
||||
|
||||
## Вывод скрипта
|
||||
|
||||
Пример успешного выполнения:
|
||||
|
||||
```
|
||||
INFO:__main__:🔌 Подключение к базе данных...
|
||||
|
||||
INFO:__main__:🗑️ Удаление данных из таблиц...
|
||||
INFO:__main__:------------------------------------------------------------
|
||||
INFO:__main__:✅ winner_verifications - удалено 0 строк
|
||||
INFO:__main__:✅ winners - удалено 2 строк
|
||||
INFO:__main__:✅ participations - удалено 1 строк
|
||||
INFO:__main__:✅ accounts - удалено 2 строк
|
||||
INFO:__main__:✅ lotteries - удалено 1 строк
|
||||
INFO:__main__:✅ users - удалено 2 строк
|
||||
INFO:__main__:------------------------------------------------------------
|
||||
|
||||
INFO:__main__:🔄 Сброс последовательностей...
|
||||
INFO:__main__:------------------------------------------------------------
|
||||
INFO:__main__:✅ users_id_seq
|
||||
INFO:__main__:✅ lotteries_id_seq
|
||||
INFO:__main__:✅ accounts_id_seq
|
||||
INFO:__main__:✅ participations_id_seq
|
||||
INFO:__main__:✅ winners_id_seq
|
||||
INFO:__main__:✅ winner_verifications_id_seq
|
||||
INFO:__main__:------------------------------------------------------------
|
||||
|
||||
INFO:__main__:============================================================
|
||||
INFO:__main__:🎉 ВСЕ ДАННЫЕ УСПЕШНО УДАЛЕНЫ ИЗ БАЗЫ ДАННЫХ!
|
||||
INFO:__main__:============================================================
|
||||
|
||||
INFO:__main__:Следующие ID начнутся с 1:
|
||||
INFO:__main__: - users.id
|
||||
INFO:__main__: - lotteries.id
|
||||
INFO:__main__: - accounts.id
|
||||
INFO:__main__: - participations.id
|
||||
INFO:__main__: - winners.id
|
||||
INFO:__main__: - winner_verifications.id
|
||||
|
||||
INFO:__main__:✅ Соединение с БД закрыто
|
||||
```
|
||||
|
||||
## Когда использовать
|
||||
|
||||
### ✅ Безопасные сценарии:
|
||||
|
||||
- **Разработка/тестирование**: Очистка тестовых данных
|
||||
- **Staging окружение**: Подготовка к тестированию
|
||||
- **Миграция**: Перед полным переносом данных
|
||||
- **Сброс демо**: Возврат к чистому состоянию
|
||||
|
||||
### ❌ НЕ использовать:
|
||||
|
||||
- **Production база**: НИКОГДА не запускайте на рабочей БД
|
||||
- **Активные розыгрыши**: Если есть незавершенные розыгрыши
|
||||
- **Без бэкапа**: Всегда делайте резервную копию перед очисткой
|
||||
|
||||
## Безопасность
|
||||
|
||||
### Файл читает DATABASE_URL из .env:
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgresql+asyncpg://user:password@host:port/database
|
||||
```
|
||||
|
||||
### Проверка перед запуском:
|
||||
|
||||
```bash
|
||||
# Проверьте что вы на правильной базе
|
||||
grep DATABASE_URL .env
|
||||
```
|
||||
|
||||
### Создание бэкапа (ОБЯЗАТЕЛЬНО для production):
|
||||
|
||||
```bash
|
||||
# PostgreSQL
|
||||
pg_dump -h localhost -U bot_user -d bot_db > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
# Восстановление
|
||||
psql -h localhost -U bot_user -d bot_db < backup_20251116_140000.sql
|
||||
```
|
||||
|
||||
## Альтернативы
|
||||
|
||||
### Мягкая очистка (сохранение структуры):
|
||||
|
||||
```sql
|
||||
-- Очистить только данные розыгрышей
|
||||
DELETE FROM winners;
|
||||
DELETE FROM participations;
|
||||
DELETE FROM lotteries;
|
||||
```
|
||||
|
||||
### Выборочная очистка:
|
||||
|
||||
```sql
|
||||
-- Удалить только тестовые данные
|
||||
DELETE FROM users WHERE club_card_number LIKE 'TEST%';
|
||||
```
|
||||
|
||||
## Требования
|
||||
|
||||
- Python 3.12+
|
||||
- Установленные зависимости (`pip install -r requirements.txt`)
|
||||
- Файл `.env` с корректным `DATABASE_URL`
|
||||
- Права на изменение данных в БД
|
||||
|
||||
## Технические детали
|
||||
|
||||
### Используемые библиотеки:
|
||||
|
||||
```python
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
```
|
||||
|
||||
### Асинхронное выполнение:
|
||||
|
||||
```python
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text('DELETE FROM table_name'))
|
||||
```
|
||||
|
||||
### Обработка транзакций:
|
||||
|
||||
- `engine.begin()` - автоматический COMMIT при успехе
|
||||
- `engine.dispose()` - закрытие всех соединений
|
||||
|
||||
## Отладка
|
||||
|
||||
### Проблемы с подключением:
|
||||
|
||||
```bash
|
||||
# Проверка доступности БД
|
||||
psql -h 192.168.0.102 -U bot_user -d bot_db -c "SELECT 1"
|
||||
```
|
||||
|
||||
### Проверка прав:
|
||||
|
||||
```sql
|
||||
-- В psql
|
||||
SELECT has_database_privilege('bot_user', 'bot_db', 'CONNECT');
|
||||
SELECT has_table_privilege('bot_user', 'users', 'DELETE');
|
||||
```
|
||||
|
||||
### Логи:
|
||||
|
||||
Скрипт использует стандартный logging с уровнем INFO. Для отладки:
|
||||
|
||||
```python
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
```
|
||||
|
||||
## См. также
|
||||
|
||||
- `docs/ADMIN_GUIDE.md` - Руководство администратора
|
||||
- `scripts/setup_postgres.sh` - Настройка PostgreSQL
|
||||
- `migrations/` - Миграции базы данных
|
||||
- `Makefile` - Все доступные команды
|
||||
97
scripts/clear_database.py
Normal file
97
scripts/clear_database.py
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт для полной очистки базы данных
|
||||
Удаляет все данные из всех таблиц и сбрасывает счетчики ID
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def clear_database():
|
||||
"""Очистка всех таблиц и сброс последовательностей"""
|
||||
|
||||
# Читаем DATABASE_URL из .env
|
||||
with open('.env', 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('DATABASE_URL='):
|
||||
DATABASE_URL = line.split('=', 1)[1].strip().strip('"').strip("'")
|
||||
break
|
||||
|
||||
logger.info('🔌 Подключение к базе данных...')
|
||||
engine = create_async_engine(DATABASE_URL)
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
# Отключаем ограничения внешних ключей
|
||||
await conn.execute(text('SET CONSTRAINTS ALL DEFERRED'))
|
||||
|
||||
# Очищаем таблицы в правильном порядке (от зависимых к независимым)
|
||||
tables = [
|
||||
'winner_verifications',
|
||||
'winners',
|
||||
'participations',
|
||||
'accounts',
|
||||
'lotteries',
|
||||
'users'
|
||||
]
|
||||
|
||||
logger.info('')
|
||||
logger.info('🗑️ Удаление данных из таблиц...')
|
||||
logger.info('-' * 60)
|
||||
|
||||
for table in tables:
|
||||
result = await conn.execute(text(f'DELETE FROM {table}'))
|
||||
logger.info(f'✅ {table:25} - удалено {result.rowcount:5} строк')
|
||||
|
||||
logger.info('-' * 60)
|
||||
|
||||
# Сбрасываем последовательности (auto-increment)
|
||||
sequences = [
|
||||
'users_id_seq',
|
||||
'lotteries_id_seq',
|
||||
'accounts_id_seq',
|
||||
'participations_id_seq',
|
||||
'winners_id_seq',
|
||||
'winner_verifications_id_seq'
|
||||
]
|
||||
|
||||
logger.info('')
|
||||
logger.info('🔄 Сброс последовательностей...')
|
||||
logger.info('-' * 60)
|
||||
|
||||
for seq in sequences:
|
||||
try:
|
||||
await conn.execute(text(f'ALTER SEQUENCE {seq} RESTART WITH 1'))
|
||||
logger.info(f'✅ {seq}')
|
||||
except Exception as e:
|
||||
logger.warning(f'⚠️ {seq}: {e}')
|
||||
|
||||
logger.info('-' * 60)
|
||||
|
||||
logger.info('')
|
||||
logger.info('=' * 60)
|
||||
logger.info('🎉 ВСЕ ДАННЫЕ УСПЕШНО УДАЛЕНЫ ИЗ БАЗЫ ДАННЫХ!')
|
||||
logger.info('=' * 60)
|
||||
logger.info('')
|
||||
logger.info('Следующие ID начнутся с 1:')
|
||||
logger.info(' - users.id')
|
||||
logger.info(' - lotteries.id')
|
||||
logger.info(' - accounts.id')
|
||||
logger.info(' - participations.id')
|
||||
logger.info(' - winners.id')
|
||||
logger.info(' - winner_verifications.id')
|
||||
logger.info('')
|
||||
|
||||
finally:
|
||||
await engine.dispose()
|
||||
logger.info('✅ Соединение с БД закрыто')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(clear_database())
|
||||
@@ -1,11 +1,12 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from .database import Base
|
||||
import secrets
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Модель пользователя"""
|
||||
"""Модель пользователя с регистрацией"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
@@ -13,16 +14,68 @@ class User(Base):
|
||||
username = Column(String(255))
|
||||
first_name = Column(String(255))
|
||||
last_name = Column(String(255))
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
phone = Column(String(20), nullable=True) # Телефон для верификации
|
||||
club_card_number = Column(String(50), unique=True, nullable=True, index=True) # Номер клубной карты
|
||||
is_registered = Column(Boolean, default=False) # Прошел ли полную регистрацию
|
||||
is_admin = Column(Boolean, default=False)
|
||||
# Клиентский счет в формате: XX-XX-XX-XX-XX-XX-XX (7 пар цифр через дефис)
|
||||
account_number = Column(String(20), unique=True, nullable=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Секретный код для верификации выигрыша (генерируется при регистрации)
|
||||
verification_code = Column(String(10), unique=True, nullable=True)
|
||||
|
||||
# Связи
|
||||
accounts = relationship("Account", back_populates="owner", cascade="all, delete-orphan")
|
||||
participations = relationship("Participation", back_populates="user")
|
||||
winners = relationship("Winner", back_populates="user")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(telegram_id={self.telegram_id}, username={self.username})>"
|
||||
return f"<User(telegram_id={self.telegram_id}, club_card={self.club_card_number})>"
|
||||
|
||||
def generate_verification_code(self):
|
||||
"""Генерирует уникальный код верификации"""
|
||||
self.verification_code = secrets.token_hex(4).upper() # 8-символьный код
|
||||
|
||||
|
||||
class Account(Base):
|
||||
"""Модель счета клиента (может быть несколько у одного пользователя)"""
|
||||
__tablename__ = "accounts"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
account_number = Column(String(20), unique=True, nullable=False, index=True) # XX-XX-XX-XX-XX-XX-XX
|
||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
is_active = Column(Boolean, default=True) # Активен ли счет
|
||||
|
||||
# Связи
|
||||
owner = relationship("User", back_populates="accounts")
|
||||
participations = relationship("Participation", back_populates="account")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Account(number={self.account_number}, owner_id={self.owner_id})>"
|
||||
|
||||
|
||||
class WinnerVerification(Base):
|
||||
"""Модель верификации победителя"""
|
||||
__tablename__ = "winner_verifications"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
winner_id = Column(Integer, ForeignKey("winners.id"), nullable=False, unique=True)
|
||||
verification_token = Column(String(32), unique=True, nullable=False) # Токен для подтверждения
|
||||
is_verified = Column(Boolean, default=False)
|
||||
verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False) # Срок действия токена
|
||||
|
||||
# Связи
|
||||
winner = relationship("Winner", back_populates="verification")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<WinnerVerification(winner_id={self.winner_id}, verified={self.is_verified})>"
|
||||
|
||||
@staticmethod
|
||||
def generate_token():
|
||||
"""Генерирует уникальный токен верификации"""
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
class Lottery(Base):
|
||||
@@ -60,14 +113,16 @@ class Participation(Base):
|
||||
__tablename__ = "participations"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Опционально
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False)
|
||||
account_number = Column(String(20), nullable=True, index=True) # Счет участника (XX-XX-XX-XX-XX-XX-XX)
|
||||
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=True) # Привязка к счету
|
||||
account_number = Column(String(20), nullable=True, index=True) # Дублируем для быстрого доступа
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Связи
|
||||
user = relationship("User", back_populates="participations")
|
||||
lottery = relationship("Lottery", back_populates="participations")
|
||||
account = relationship("Account", back_populates="participations")
|
||||
|
||||
def __repr__(self):
|
||||
if self.account_number:
|
||||
@@ -81,16 +136,22 @@ class Winner(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Опционально
|
||||
account_number = Column(String(20), nullable=True, index=True) # Счет победителя
|
||||
place = Column(Integer, nullable=False) # Место (1, 2, 3...)
|
||||
prize = Column(String(500)) # Описание приза
|
||||
is_manual = Column(Boolean, default=False) # Был ли установлен вручную
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
account_number = Column(String(20), nullable=True, index=True)
|
||||
place = Column(Integer, nullable=False)
|
||||
prize = Column(String(500))
|
||||
is_manual = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Статус подтверждения выигрыша
|
||||
is_notified = Column(Boolean, default=False) # Отправлено ли уведомление
|
||||
is_claimed = Column(Boolean, default=False) # Подтвердил ли победитель
|
||||
claimed_at = Column(DateTime(timezone=True), nullable=True) # Время подтверждения
|
||||
|
||||
# Связи
|
||||
user = relationship("User")
|
||||
user = relationship("User", back_populates="winners")
|
||||
lottery = relationship("Lottery")
|
||||
verification = relationship("WinnerVerification", back_populates="winner", uselist=False)
|
||||
|
||||
def __repr__(self):
|
||||
if self.account_number:
|
||||
|
||||
287
src/core/registration_services.py
Normal file
287
src/core/registration_services.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""Сервисы для регистрации, управления счетами и верификации"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from .models import User, Account, Winner, WinnerVerification
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import secrets
|
||||
|
||||
|
||||
class RegistrationService:
|
||||
"""Сервис для регистрации пользователей"""
|
||||
|
||||
@staticmethod
|
||||
async def register_user(
|
||||
session: AsyncSession,
|
||||
telegram_id: int,
|
||||
club_card_number: str,
|
||||
phone: Optional[str] = None
|
||||
) -> User:
|
||||
"""Зарегистрировать нового пользователя с клубной картой"""
|
||||
# Проверяем, не занята ли клубная карта
|
||||
existing = await session.execute(
|
||||
select(User).where(User.club_card_number == club_card_number)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ValueError(f"Клубная карта {club_card_number} уже зарегистрирована")
|
||||
|
||||
# Находим пользователя по telegram_id
|
||||
result = await session.execute(
|
||||
select(User).where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise ValueError("Пользователь не найден")
|
||||
|
||||
# Обновляем данные пользователя
|
||||
user.club_card_number = club_card_number
|
||||
user.phone = phone
|
||||
user.is_registered = True
|
||||
user.generate_verification_code()
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
async def get_user_by_club_card(
|
||||
session: AsyncSession,
|
||||
club_card_number: str
|
||||
) -> Optional[User]:
|
||||
"""Найти пользователя по номеру клубной карты"""
|
||||
result = await session.execute(
|
||||
select(User).where(User.club_card_number == club_card_number)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_user_by_verification_code(
|
||||
session: AsyncSession,
|
||||
verification_code: str
|
||||
) -> Optional[User]:
|
||||
"""Найти пользователя по коду верификации"""
|
||||
result = await session.execute(
|
||||
select(User).where(User.verification_code == verification_code)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
class AccountService:
|
||||
"""Сервис для управления счетами пользователей"""
|
||||
|
||||
@staticmethod
|
||||
async def create_account(
|
||||
session: AsyncSession,
|
||||
club_card_number: str,
|
||||
account_number: str
|
||||
) -> Account:
|
||||
"""Создать новый счет для пользователя по номеру клубной карты"""
|
||||
# Находим владельца по клубной карте
|
||||
user_result = await session.execute(
|
||||
select(User).where(User.club_card_number == club_card_number)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise ValueError(f"Пользователь с клубной картой {club_card_number} не найден")
|
||||
|
||||
# Проверяем, не существует ли уже такой счет
|
||||
existing = await session.execute(
|
||||
select(Account).where(Account.account_number == account_number)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ValueError(f"Счет {account_number} уже существует")
|
||||
|
||||
# Создаем счет
|
||||
account = Account(
|
||||
account_number=account_number,
|
||||
owner_id=user.id,
|
||||
is_active=True
|
||||
)
|
||||
session.add(account)
|
||||
await session.commit()
|
||||
await session.refresh(account)
|
||||
|
||||
return account
|
||||
|
||||
@staticmethod
|
||||
async def get_account_owner(
|
||||
session: AsyncSession,
|
||||
account_number: str
|
||||
) -> Optional[User]:
|
||||
"""Найти владельца счета"""
|
||||
result = await session.execute(
|
||||
select(Account).where(
|
||||
and_(
|
||||
Account.account_number == account_number,
|
||||
Account.is_active == True
|
||||
)
|
||||
)
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
|
||||
if not account:
|
||||
return None
|
||||
|
||||
# Получаем владельца
|
||||
owner_result = await session.execute(
|
||||
select(User).where(User.id == account.owner_id)
|
||||
)
|
||||
return owner_result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_user_accounts(
|
||||
session: AsyncSession,
|
||||
user_id: int
|
||||
) -> List[Account]:
|
||||
"""Получить все счета пользователя"""
|
||||
result = await session.execute(
|
||||
select(Account)
|
||||
.where(Account.owner_id == user_id)
|
||||
.order_by(Account.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def deactivate_account(
|
||||
session: AsyncSession,
|
||||
account_number: str
|
||||
) -> bool:
|
||||
"""Деактивировать счет"""
|
||||
result = await session.execute(
|
||||
select(Account).where(Account.account_number == account_number)
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
|
||||
if not account:
|
||||
return False
|
||||
|
||||
account.is_active = False
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
class WinnerNotificationService:
|
||||
"""Сервис для уведомления победителей"""
|
||||
|
||||
@staticmethod
|
||||
async def create_verification_token(
|
||||
session: AsyncSession,
|
||||
winner_id: int,
|
||||
expires_hours: int = 24
|
||||
) -> WinnerVerification:
|
||||
"""Создать токен верификации для победителя"""
|
||||
# Проверяем, нет ли уже токена
|
||||
existing = await session.execute(
|
||||
select(WinnerVerification).where(WinnerVerification.winner_id == winner_id)
|
||||
)
|
||||
verification = existing.scalar_one_or_none()
|
||||
|
||||
if verification:
|
||||
# Обновляем существующий токен
|
||||
verification.verification_token = WinnerVerification.generate_token()
|
||||
verification.created_at = datetime.now(timezone.utc)
|
||||
verification.expires_at = datetime.now(timezone.utc) + timedelta(hours=expires_hours)
|
||||
verification.is_verified = False
|
||||
verification.verified_at = None
|
||||
else:
|
||||
# Создаем новый
|
||||
verification = WinnerVerification(
|
||||
winner_id=winner_id,
|
||||
verification_token=WinnerVerification.generate_token(),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=expires_hours)
|
||||
)
|
||||
session.add(verification)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(verification)
|
||||
return verification
|
||||
|
||||
@staticmethod
|
||||
async def verify_winner(
|
||||
session: AsyncSession,
|
||||
verification_code: str,
|
||||
lottery_id: int
|
||||
) -> Optional[Winner]:
|
||||
"""Подтвердить выигрыш по коду верификации пользователя"""
|
||||
# Находим пользователя по коду
|
||||
user_result = await session.execute(
|
||||
select(User).where(User.verification_code == verification_code)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# Находим выигрыш этого пользователя в данном розыгрыше
|
||||
winner_result = await session.execute(
|
||||
select(Winner).where(
|
||||
and_(
|
||||
Winner.user_id == user.id,
|
||||
Winner.lottery_id == lottery_id,
|
||||
Winner.is_claimed == False
|
||||
)
|
||||
)
|
||||
)
|
||||
winner = winner_result.scalar_one_or_none()
|
||||
|
||||
if not winner:
|
||||
# Проверяем, может быть выигрыш по счету
|
||||
# Получаем все счета пользователя
|
||||
accounts_result = await session.execute(
|
||||
select(Account).where(Account.owner_id == user.id)
|
||||
)
|
||||
accounts = accounts_result.scalars().all()
|
||||
account_numbers = [acc.account_number for acc in accounts]
|
||||
|
||||
# Ищем выигрыш по любому из счетов
|
||||
winner_result = await session.execute(
|
||||
select(Winner).where(
|
||||
and_(
|
||||
Winner.account_number.in_(account_numbers),
|
||||
Winner.lottery_id == lottery_id,
|
||||
Winner.is_claimed == False
|
||||
)
|
||||
)
|
||||
)
|
||||
winner = winner_result.scalar_one_or_none()
|
||||
|
||||
if not winner:
|
||||
return None
|
||||
|
||||
# Помечаем как подтвержденный
|
||||
winner.is_claimed = True
|
||||
|
||||
# Обновляем верификацию если есть
|
||||
verification_result = await session.execute(
|
||||
select(WinnerVerification).where(WinnerVerification.winner_id == winner.id)
|
||||
)
|
||||
verification = verification_result.scalar_one_or_none()
|
||||
|
||||
if verification:
|
||||
verification.is_verified = True
|
||||
verification.verified_at = datetime.now(timezone.utc)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(winner)
|
||||
|
||||
return winner
|
||||
|
||||
@staticmethod
|
||||
async def get_unverified_winners(
|
||||
session: AsyncSession,
|
||||
lottery_id: int
|
||||
) -> List[Winner]:
|
||||
"""Получить список неподтвержденных победителей"""
|
||||
result = await session.execute(
|
||||
select(Winner).where(
|
||||
and_(
|
||||
Winner.lottery_id == lottery_id,
|
||||
Winner.is_claimed == False
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
@@ -1,7 +1,7 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, delete
|
||||
from sqlalchemy.orm import selectinload
|
||||
from .models import User, Lottery, Participation, Winner
|
||||
from .models import User, Lottery, Participation, Winner, Account
|
||||
from typing import List, Optional, Dict, Any
|
||||
from ..utils.account_utils import validate_account_number, format_account_number
|
||||
import random
|
||||
@@ -251,8 +251,20 @@ class LotteryService:
|
||||
if not lottery or lottery.is_completed:
|
||||
return {}
|
||||
|
||||
# Получаем всех участников
|
||||
participants = [p.user for p in lottery.participations]
|
||||
# Получаем всех участников (включая тех, у кого нет user)
|
||||
participants = []
|
||||
for p in lottery.participations:
|
||||
if p.user:
|
||||
participants.append(p.user)
|
||||
else:
|
||||
# Создаем временный объект для участников без пользователя
|
||||
# Храним только номер счета
|
||||
participants.append(type('obj', (object,), {
|
||||
'id': None,
|
||||
'telegram_id': None,
|
||||
'account_number': p.account_number
|
||||
})())
|
||||
|
||||
if not participants:
|
||||
return {}
|
||||
|
||||
@@ -270,7 +282,7 @@ class LotteryService:
|
||||
# Находим пользователя среди участников
|
||||
manual_winner = None
|
||||
for participant in remaining_participants:
|
||||
if participant.telegram_id == manual_winners[place_str]:
|
||||
if hasattr(participant, 'telegram_id') and participant.telegram_id == manual_winners[place_str]:
|
||||
manual_winner = participant
|
||||
break
|
||||
|
||||
@@ -295,9 +307,11 @@ class LotteryService:
|
||||
|
||||
# Сохраняем победителей в базу данных
|
||||
for place, winner_info in results.items():
|
||||
user_obj = winner_info['user']
|
||||
winner = Winner(
|
||||
lottery_id=lottery_id,
|
||||
user_id=winner_info['user'].id,
|
||||
user_id=user_obj.id if hasattr(user_obj, 'id') and user_obj.id else None,
|
||||
account_number=user_obj.account_number if hasattr(user_obj, 'account_number') else None,
|
||||
place=place,
|
||||
prize=winner_info['prize'],
|
||||
is_manual=winner_info['is_manual']
|
||||
@@ -306,15 +320,17 @@ class LotteryService:
|
||||
|
||||
# Обновляем статус розыгрыша
|
||||
lottery.is_completed = True
|
||||
lottery.draw_results = {
|
||||
str(place): {
|
||||
'user_id': info['user'].id,
|
||||
'telegram_id': info['user'].telegram_id,
|
||||
'username': info['user'].username,
|
||||
lottery.draw_results = {}
|
||||
for place, info in results.items():
|
||||
user_obj = info['user']
|
||||
lottery.draw_results[str(place)] = {
|
||||
'user_id': user_obj.id if hasattr(user_obj, 'id') and user_obj.id else None,
|
||||
'telegram_id': user_obj.telegram_id if hasattr(user_obj, 'telegram_id') else None,
|
||||
'username': user_obj.username if hasattr(user_obj, 'username') else None,
|
||||
'account_number': user_obj.account_number if hasattr(user_obj, 'account_number') else None,
|
||||
'prize': info['prize'],
|
||||
'is_manual': info['is_manual']
|
||||
} for place, info in results.items()
|
||||
}
|
||||
}
|
||||
|
||||
await session.commit()
|
||||
return results
|
||||
|
||||
585
src/handlers/admin_account_handlers.py
Normal file
585
src/handlers/admin_account_handlers.py
Normal file
@@ -0,0 +1,585 @@
|
||||
"""Админские обработчики для управления счетами и верификации"""
|
||||
from aiogram import Router, F, Bot
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.registration_services import AccountService, WinnerNotificationService, RegistrationService
|
||||
from src.core.services import UserService, LotteryService, ParticipationService
|
||||
from src.core.models import User, Winner, Account, Participation
|
||||
from src.core.config import ADMIN_IDS
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class AddAccountStates(StatesGroup):
|
||||
waiting_for_data = State()
|
||||
choosing_lottery = State()
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Проверка прав администратора"""
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
@router.message(Command("add_account"))
|
||||
async def add_account_command(message: Message, state: FSMContext):
|
||||
"""
|
||||
Добавить счет пользователю по клубной карте
|
||||
Формат: /add_account <club_card> <account_number>
|
||||
Или: /add_account (затем вводить данные построчно)
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
parts = message.text.split(maxsplit=2)
|
||||
|
||||
# Если данные указаны в команде
|
||||
if len(parts) == 3:
|
||||
club_card = parts[1]
|
||||
account_number = parts[2]
|
||||
await process_single_account(message, club_card, account_number, state)
|
||||
else:
|
||||
# Запрашиваем данные
|
||||
await state.set_state(AddAccountStates.waiting_for_data)
|
||||
await message.answer(
|
||||
"💳 **Добавление счетов**\n\n"
|
||||
"Отправьте данные в формате:\n"
|
||||
"`клубная_карта номер_счета`\n\n"
|
||||
"**Для одного счета:**\n"
|
||||
"`2223 11-22-33-44-55-66-77`\n\n"
|
||||
"**Для нескольких счетов (каждый с новой строки):**\n"
|
||||
"`2223 11-22-33-44-55-66-77`\n"
|
||||
"`2223 88-99-00-11-22-33-44`\n"
|
||||
"`3334 12-34-56-78-90-12-34`\n\n"
|
||||
"❌ Отправьте /cancel для отмены",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
|
||||
async def process_single_account(message: Message, club_card: str, account_number: str, state: FSMContext):
|
||||
"""Обработка одного счета"""
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
# Создаем счет
|
||||
account = await AccountService.create_account(
|
||||
session,
|
||||
club_card_number=club_card,
|
||||
account_number=account_number
|
||||
)
|
||||
|
||||
# Получаем владельца
|
||||
owner = await AccountService.get_account_owner(session, account_number)
|
||||
|
||||
# Сохраняем данные счета в state для добавления в розыгрыш
|
||||
await state.update_data(
|
||||
accounts=[{
|
||||
'club_card': club_card,
|
||||
'account_number': account_number,
|
||||
'account_id': account.id
|
||||
}]
|
||||
)
|
||||
|
||||
text = f"✅ Счет успешно добавлен!\n\n"
|
||||
text += f"🎫 Клубная карта: {club_card}\n"
|
||||
text += f"💳 Счет: {account_number}\n"
|
||||
|
||||
if owner:
|
||||
text += f"👤 Владелец: {owner.first_name}\n\n"
|
||||
|
||||
# Отправляем уведомление владельцу
|
||||
try:
|
||||
await message.bot.send_message(
|
||||
owner.telegram_id,
|
||||
f"✅ К вашему профилю добавлен счет:\n\n"
|
||||
f"💳 {account_number}\n\n"
|
||||
f"Теперь вы можете участвовать в розыгрышах с этим счетом!"
|
||||
)
|
||||
text += "📨 Владельцу отправлено уведомление\n\n"
|
||||
except Exception as e:
|
||||
text += f"⚠️ Не удалось отправить уведомление: {str(e)}\n\n"
|
||||
|
||||
# Предлагаем добавить в розыгрыш
|
||||
await show_lottery_selection(message, text, state)
|
||||
|
||||
except ValueError as e:
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Произошла ошибка: {str(e)}")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(AddAccountStates.waiting_for_data)
|
||||
async def process_accounts_data(message: Message, state: FSMContext):
|
||||
"""Обработка данных счетов (один или несколько)"""
|
||||
if message.text.strip().lower() == '/cancel':
|
||||
await state.clear()
|
||||
await message.answer("❌ Операция отменена")
|
||||
return
|
||||
|
||||
lines = message.text.strip().split('\n')
|
||||
accounts_data = []
|
||||
errors = []
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
parts = line.strip().split()
|
||||
if len(parts) != 2:
|
||||
errors.append(f"Строка {i}: неверный формат (ожидается: клубная_карта номер_счета)")
|
||||
continue
|
||||
|
||||
club_card, account_number = parts
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
account = await AccountService.create_account(
|
||||
session,
|
||||
club_card_number=club_card,
|
||||
account_number=account_number
|
||||
)
|
||||
|
||||
owner = await AccountService.get_account_owner(session, account_number)
|
||||
|
||||
accounts_data.append({
|
||||
'club_card': club_card,
|
||||
'account_number': account_number,
|
||||
'account_id': account.id,
|
||||
'owner': owner
|
||||
})
|
||||
|
||||
# Отправляем уведомление владельцу
|
||||
if owner:
|
||||
try:
|
||||
await message.bot.send_message(
|
||||
owner.telegram_id,
|
||||
f"✅ К вашему профилю добавлен счет:\n\n"
|
||||
f"💳 {account_number}\n\n"
|
||||
f"Теперь вы можете участвовать в розыгрышах!"
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
except ValueError as e:
|
||||
errors.append(f"Строка {i} ({club_card} {account_number}): {str(e)}")
|
||||
except Exception as e:
|
||||
errors.append(f"Строка {i}: {str(e)}")
|
||||
|
||||
# Формируем отчет
|
||||
text = f"📊 **Результаты добавления счетов**\n\n"
|
||||
|
||||
if accounts_data:
|
||||
text += f"✅ **Успешно добавлено: {len(accounts_data)}**\n\n"
|
||||
for acc in accounts_data:
|
||||
text += f"• {acc['club_card']} → {acc['account_number']}\n"
|
||||
if acc['owner']:
|
||||
text += f" 👤 {acc['owner'].first_name}\n"
|
||||
text += "\n"
|
||||
|
||||
if errors:
|
||||
text += f"❌ **Ошибки: {len(errors)}**\n\n"
|
||||
for error in errors[:5]: # Показываем максимум 5 ошибок
|
||||
text += f"• {error}\n"
|
||||
if len(errors) > 5:
|
||||
text += f"\n... и еще {len(errors) - 5} ошибок\n"
|
||||
|
||||
if not accounts_data:
|
||||
await message.answer(text)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Сохраняем данные и предлагаем добавить в розыгрыш
|
||||
await state.update_data(accounts=accounts_data)
|
||||
await show_lottery_selection(message, text, state)
|
||||
|
||||
|
||||
async def show_lottery_selection(message: Message, prev_text: str, state: FSMContext):
|
||||
"""Показать выбор розыгрыша для добавления счетов"""
|
||||
async with async_session_maker() as session:
|
||||
lotteries = await LotteryService.get_active_lotteries(session)
|
||||
|
||||
if not lotteries:
|
||||
await message.answer(
|
||||
prev_text + "ℹ️ Нет активных розыгрышей для добавления счетов"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await state.set_state(AddAccountStates.choosing_lottery)
|
||||
|
||||
buttons = []
|
||||
for lottery in lotteries[:10]: # Максимум 10 розыгрышей
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"🎯 {lottery.title}",
|
||||
callback_data=f"add_to_lottery_{lottery.id}"
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="❌ Пропустить", callback_data="skip_lottery_add")
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
prev_text + "➕ **Добавить счета в розыгрыш?**\n\n"
|
||||
"Выберите розыгрыш из списка:",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("add_to_lottery_"))
|
||||
async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
"""Добавить счета в выбранный розыгрыш"""
|
||||
lottery_id = int(callback.data.split("_")[-1])
|
||||
|
||||
data = await state.get_data()
|
||||
accounts = data.get('accounts', [])
|
||||
|
||||
if not accounts:
|
||||
await callback.answer("❌ Нет данных о счетах", show_alert=True)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
success_count = 0
|
||||
errors = []
|
||||
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
|
||||
if not lottery:
|
||||
await callback.answer("❌ Розыгрыш не найден", show_alert=True)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
for acc in accounts:
|
||||
try:
|
||||
# Добавляем участие через account_id
|
||||
# Проверяем, не участвует ли уже
|
||||
existing = await session.execute(
|
||||
select(Participation).where(
|
||||
and_(
|
||||
Participation.lottery_id == lottery_id,
|
||||
Participation.account_id == acc['account_id']
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if existing.scalar_one_or_none():
|
||||
errors.append(f"{acc['account_number']}: уже участвует")
|
||||
continue
|
||||
|
||||
# Создаем участие
|
||||
participation = Participation(
|
||||
lottery_id=lottery_id,
|
||||
account_id=acc['account_id'],
|
||||
account_number=acc['account_number']
|
||||
)
|
||||
session.add(participation)
|
||||
success_count += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"{acc['account_number']}: {str(e)}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
text = f"📊 **Добавление в розыгрыш '{lottery.title}'**\n\n"
|
||||
|
||||
if success_count:
|
||||
text += f"✅ Добавлено счетов: {success_count}\n\n"
|
||||
|
||||
if errors:
|
||||
text += f"⚠️ Ошибки: {len(errors)}\n"
|
||||
for error in errors[:3]:
|
||||
text += f"• {error}\n"
|
||||
|
||||
await callback.message.edit_text(text)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "skip_lottery_add")
|
||||
async def skip_lottery_add(callback: CallbackQuery, state: FSMContext):
|
||||
"""Пропустить добавление в розыгрыш"""
|
||||
await callback.message.edit_text("✅ Счета добавлены без участия в розыгрышах")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(Command("remove_account"))
|
||||
async def remove_account_command(message: Message):
|
||||
"""
|
||||
Деактивировать счет
|
||||
Формат: /remove_account <account_number>
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
parts = message.text.split()
|
||||
if len(parts) != 2:
|
||||
await message.answer(
|
||||
"❌ Неверный формат команды\n\n"
|
||||
"Используйте: /remove_account <account_number>"
|
||||
)
|
||||
return
|
||||
|
||||
account_number = parts[1]
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
success = await AccountService.deactivate_account(session, account_number)
|
||||
|
||||
if success:
|
||||
await message.answer(f"✅ Счет {account_number} деактивирован")
|
||||
else:
|
||||
await message.answer(f"❌ Счет {account_number} не найден")
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@router.message(Command("verify_winner"))
|
||||
async def verify_winner_command(message: Message):
|
||||
"""
|
||||
Подтвердить выигрыш по коду верификации
|
||||
Формат: /verify_winner <verification_code> <lottery_id>
|
||||
Пример: /verify_winner AB12CD34 1
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
parts = message.text.split()
|
||||
if len(parts) != 3:
|
||||
await message.answer(
|
||||
"❌ Неверный формат команды\n\n"
|
||||
"Используйте:\n"
|
||||
"/verify_winner <verification_code> <lottery_id>\n\n"
|
||||
"Пример:\n"
|
||||
"/verify_winner AB12CD34 1"
|
||||
)
|
||||
return
|
||||
|
||||
verification_code = parts[1].upper()
|
||||
|
||||
try:
|
||||
lottery_id = int(parts[2])
|
||||
except ValueError:
|
||||
await message.answer("❌ lottery_id должен быть числом")
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
# Проверяем существование розыгрыша
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await message.answer(f"❌ Розыгрыш #{lottery_id} не найден")
|
||||
return
|
||||
|
||||
# Подтверждаем выигрыш
|
||||
winner = await WinnerNotificationService.verify_winner(
|
||||
session,
|
||||
verification_code=verification_code,
|
||||
lottery_id=lottery_id
|
||||
)
|
||||
|
||||
if not winner:
|
||||
await message.answer(
|
||||
f"❌ Выигрыш не найден\n\n"
|
||||
f"Возможные причины:\n"
|
||||
f"• Неверный код верификации\n"
|
||||
f"• Пользователь не является победителем в розыгрыше #{lottery_id}\n"
|
||||
f"• Выигрыш уже был подтвержден"
|
||||
)
|
||||
return
|
||||
|
||||
# Получаем пользователя
|
||||
user = await RegistrationService.get_user_by_verification_code(session, verification_code)
|
||||
|
||||
text = "✅ Выигрыш подтвержден!\n\n"
|
||||
text += f"🎯 Розыгрыш: {lottery.title}\n"
|
||||
text += f"🏆 Место: {winner.place}\n"
|
||||
text += f"🎁 Приз: {winner.prize}\n\n"
|
||||
|
||||
if user:
|
||||
text += f"👤 Победитель: {user.first_name}\n"
|
||||
text += f"🎫 Клубная карта: {user.club_card_number}\n"
|
||||
if user.phone:
|
||||
text += f"📱 Телефон: {user.phone}\n"
|
||||
|
||||
# Отправляем уведомление победителю
|
||||
try:
|
||||
bot = message.bot
|
||||
await bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ Ваш выигрыш подтвержден!\n\n"
|
||||
f"🎯 Розыгрыш: {lottery.title}\n"
|
||||
f"🏆 Место: {winner.place}\n"
|
||||
f"🎁 Приз: {winner.prize}\n\n"
|
||||
f"Администратор свяжется с вами для получения приза."
|
||||
)
|
||||
text += "\n📨 Победителю отправлено уведомление"
|
||||
except Exception as e:
|
||||
text += f"\n⚠️ Не удалось отправить уведомление: {str(e)}"
|
||||
|
||||
if winner.account_number:
|
||||
text += f"💳 Счет: {winner.account_number}\n"
|
||||
|
||||
await message.answer(text)
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@router.message(Command("winner_status"))
|
||||
async def winner_status_command(message: Message):
|
||||
"""
|
||||
Показать статус всех победителей розыгрыша
|
||||
Формат: /winner_status <lottery_id>
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
parts = message.text.split()
|
||||
if len(parts) != 2:
|
||||
await message.answer(
|
||||
"❌ Неверный формат команды\n\n"
|
||||
"Используйте: /winner_status <lottery_id>"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
lottery_id = int(parts[1])
|
||||
except ValueError:
|
||||
await message.answer("❌ lottery_id должен быть числом")
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await message.answer(f"❌ Розыгрыш #{lottery_id} не найден")
|
||||
return
|
||||
|
||||
winners = await LotteryService.get_winners(session, lottery_id)
|
||||
|
||||
if not winners:
|
||||
await message.answer(f"В розыгрыше '{lottery.title}' пока нет победителей")
|
||||
return
|
||||
|
||||
text = f"🏆 Победители розыгрыша '{lottery.title}':\n\n"
|
||||
|
||||
for winner in winners:
|
||||
status_icon = "✅" if winner.is_claimed else "⏳"
|
||||
notified_icon = "📨" if winner.is_notified else "📭"
|
||||
|
||||
text += f"{status_icon} {winner.place} место - {winner.prize}\n"
|
||||
|
||||
# Получаем информацию о победителе
|
||||
async with async_session_maker() as session:
|
||||
if 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:
|
||||
text += f" 👤 {user.first_name}"
|
||||
if user.club_card_number:
|
||||
text += f" (КК: {user.club_card_number})"
|
||||
text += "\n"
|
||||
|
||||
if winner.account_number:
|
||||
text += f" 💳 {winner.account_number}\n"
|
||||
|
||||
# Статус подтверждения
|
||||
if winner.is_claimed:
|
||||
text += f" ✅ Подтвержден\n"
|
||||
else:
|
||||
text += f" ⏳ Ожидает подтверждения\n"
|
||||
|
||||
text += "\n"
|
||||
|
||||
await message.answer(text)
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@router.message(Command("user_info"))
|
||||
async def user_info_command(message: Message):
|
||||
"""
|
||||
Показать информацию о пользователе
|
||||
Формат: /user_info <club_card>
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
parts = message.text.split()
|
||||
if len(parts) != 2:
|
||||
await message.answer(
|
||||
"❌ Неверный формат команды\n\n"
|
||||
"Используйте: /user_info <club_card>"
|
||||
)
|
||||
return
|
||||
|
||||
club_card = parts[1]
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
user = await RegistrationService.get_user_by_club_card(session, club_card)
|
||||
|
||||
if not user:
|
||||
await message.answer(f"❌ Пользователь с клубной картой {club_card} не найден")
|
||||
return
|
||||
|
||||
# Получаем счета
|
||||
accounts = await AccountService.get_user_accounts(session, user.id)
|
||||
|
||||
# Получаем выигрыши
|
||||
winners_result = await session.execute(
|
||||
select(Winner).where(Winner.user_id == user.id)
|
||||
)
|
||||
winners = winners_result.scalars().all()
|
||||
|
||||
text = f"👤 Информация о пользователе\n\n"
|
||||
text += f"🎫 Клубная карта: {user.club_card_number}\n"
|
||||
text += f"👤 Имя: {user.first_name}"
|
||||
if user.last_name:
|
||||
text += f" {user.last_name}"
|
||||
text += "\n"
|
||||
|
||||
if user.username:
|
||||
text += f"📱 Telegram: @{user.username}\n"
|
||||
|
||||
if user.phone:
|
||||
text += f"📞 Телефон: {user.phone}\n"
|
||||
|
||||
text += f"🔑 Код верификации: {user.verification_code}\n"
|
||||
text += f"📅 Зарегистрирован: {user.created_at.strftime('%d.%m.%Y')}\n\n"
|
||||
|
||||
# Счета
|
||||
text += f"💳 Счета ({len(accounts)}):\n"
|
||||
if accounts:
|
||||
for acc in accounts:
|
||||
status = "✅" if acc.is_active else "❌"
|
||||
text += f" {status} {acc.account_number}\n"
|
||||
else:
|
||||
text += " Нет счетов\n"
|
||||
|
||||
# Выигрыши
|
||||
text += f"\n🏆 Выигрыши ({len(winners)}):\n"
|
||||
if winners:
|
||||
for w in winners:
|
||||
status = "✅" if w.is_claimed else "⏳"
|
||||
text += f" {status} {w.place} место - {w.prize}\n"
|
||||
else:
|
||||
text += " Нет выигрышей\n"
|
||||
|
||||
await message.answer(text)
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
@@ -471,9 +471,13 @@ async def show_lottery_participants(callback: CallbackQuery):
|
||||
|
||||
for i, participation in enumerate(lottery.participations[:20], 1): # Показываем первых 20
|
||||
user = participation.user
|
||||
username = f"@{user.username}" if user.username else "Нет username"
|
||||
text += f"{i}. {user.first_name} {user.last_name or ''}\n"
|
||||
text += f" {username} | ID: {user.telegram_id}\n"
|
||||
if user:
|
||||
username = f"@{user.username}" if user.username else "Нет username"
|
||||
text += f"{i}. {user.first_name} {user.last_name or ''}\n"
|
||||
text += f" {username} | ID: {user.telegram_id}\n"
|
||||
else:
|
||||
# Если пользователя нет, показываем номер счета
|
||||
text += f"{i}. Счет: {participation.account_number or 'Не указан'}\n"
|
||||
text += f" Участвует с: {participation.created_at.strftime('%d.%m %H:%M')}\n\n"
|
||||
|
||||
if len(lottery.participations) > 20:
|
||||
@@ -1683,11 +1687,13 @@ async def redirect_to_edit_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
parts = callback.data.split("_")
|
||||
if len(parts) == 3: # admin_edit_123
|
||||
lottery_id = int(parts[2])
|
||||
# Подменяем callback_data для обработки существующим хэндлером
|
||||
callback.data = f"admin_edit_lottery_select_{lottery_id}"
|
||||
# Напрямую вызываем обработчик вместо подмены callback_data
|
||||
await state.update_data(edit_lottery_id=lottery_id)
|
||||
await choose_edit_field(callback, state)
|
||||
else:
|
||||
# Если формат другой, то это уже правильный callback
|
||||
lottery_id = int(callback.data.split("_")[-1])
|
||||
await state.update_data(edit_lottery_id=lottery_id)
|
||||
await choose_edit_field(callback, state)
|
||||
|
||||
|
||||
@@ -2027,8 +2033,8 @@ async def handle_set_winner_from_lottery(callback: CallbackQuery, state: FSMCont
|
||||
|
||||
lottery_id = int(callback.data.split("_")[-1])
|
||||
|
||||
# Перенаправляем на стандартный обработчик
|
||||
callback.data = f"admin_choose_winner_lottery_{lottery_id}"
|
||||
# Напрямую вызываем обработчик вместо подмены callback_data
|
||||
await state.update_data(winner_lottery_id=lottery_id)
|
||||
await choose_winner_place(callback, state)
|
||||
|
||||
|
||||
@@ -2610,11 +2616,12 @@ async def conduct_lottery_draw(callback: CallbackQuery):
|
||||
await callback.answer("Нет участников для розыгрыша", show_alert=True)
|
||||
return
|
||||
|
||||
# Проводим розыгрыш
|
||||
from ..display.conduct_draw import conduct_draw
|
||||
winners = await conduct_draw(lottery_id)
|
||||
# Проводим розыгрыш через сервис
|
||||
winners_dict = await LotteryService.conduct_draw(session, lottery_id)
|
||||
|
||||
if winners:
|
||||
if winners_dict:
|
||||
# Получаем победителей из базы
|
||||
winners = await LotteryService.get_winners(session, lottery_id)
|
||||
text = f"🎉 Розыгрыш '{lottery.title}' завершён!\n\n"
|
||||
text += "🏆 Победители:\n"
|
||||
for winner in winners:
|
||||
|
||||
314
src/handlers/redraw_handlers.py
Normal file
314
src/handlers/redraw_handlers.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Команды для повторного розыгрыша неподтвержденных выигрышей"""
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.filters import Command
|
||||
from sqlalchemy import select, and_
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import random
|
||||
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.registration_services import AccountService, WinnerNotificationService
|
||||
from src.core.services import LotteryService
|
||||
from src.core.models import User, Winner
|
||||
from src.core.config import ADMIN_IDS
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Проверка прав администратора"""
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
@router.message(Command("check_unclaimed"))
|
||||
async def check_unclaimed_winners(message: Message):
|
||||
"""
|
||||
Проверить неподтвержденные выигрыши (более 24 часов)
|
||||
Формат: /check_unclaimed <lottery_id>
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
parts = message.text.split()
|
||||
if len(parts) != 2:
|
||||
await message.answer(
|
||||
"❌ Неверный формат команды\n\n"
|
||||
"Используйте: /check_unclaimed <lottery_id>"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
lottery_id = int(parts[1])
|
||||
except ValueError:
|
||||
await message.answer("❌ lottery_id должен быть числом")
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy.orm import selectinload
|
||||
from src.core.models import Lottery
|
||||
|
||||
# Загружаем розыгрыш с участниками
|
||||
lottery_result = await session.execute(
|
||||
select(Lottery)
|
||||
.options(selectinload(Lottery.participations))
|
||||
.where(Lottery.id == lottery_id)
|
||||
)
|
||||
lottery = lottery_result.scalar_one_or_none()
|
||||
|
||||
if not lottery:
|
||||
await message.answer(f"❌ Розыгрыш #{lottery_id} не найден")
|
||||
return
|
||||
|
||||
winners = await LotteryService.get_winners(session, lottery_id)
|
||||
|
||||
if not winners:
|
||||
await message.answer(f"В розыгрыше '{lottery.title}' нет победителей")
|
||||
return
|
||||
|
||||
# Находим неподтвержденные выигрыши старше 24 часов
|
||||
now = datetime.now(timezone.utc)
|
||||
unclaimed = []
|
||||
|
||||
for winner in winners:
|
||||
if not winner.is_claimed and winner.is_notified:
|
||||
# Проверяем, прошло ли 24 часа
|
||||
time_passed = now - winner.created_at
|
||||
if time_passed.total_seconds() > 24 * 3600: # 24 часа
|
||||
unclaimed.append({
|
||||
'winner': winner,
|
||||
'hours_passed': int(time_passed.total_seconds() / 3600)
|
||||
})
|
||||
|
||||
if not unclaimed:
|
||||
await message.answer(
|
||||
f"✅ Все победители розыгрыша '{lottery.title}' подтвердили выигрыш\n"
|
||||
f"или срок подтверждения еще не истек."
|
||||
)
|
||||
return
|
||||
|
||||
text = f"⚠️ **Неподтвержденные выигрыши в розыгрыше '{lottery.title}':**\n\n"
|
||||
|
||||
for item in unclaimed:
|
||||
winner = item['winner']
|
||||
hours = item['hours_passed']
|
||||
|
||||
text += f"🏆 {winner.place} место - {winner.prize}\n"
|
||||
|
||||
# Получаем информацию о победителе
|
||||
async with async_session_maker() as session:
|
||||
if 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:
|
||||
text += f" 👤 {user.first_name}"
|
||||
if user.club_card_number:
|
||||
text += f" (КК: {user.club_card_number})"
|
||||
text += "\n"
|
||||
|
||||
if winner.account_number:
|
||||
text += f" 💳 {winner.account_number}\n"
|
||||
|
||||
text += f" ⏰ Прошло: {hours} часов\n\n"
|
||||
|
||||
text += f"\n📊 Всего неподтвержденных: {len(unclaimed)}\n\n"
|
||||
text += f"Используйте /redraw {lottery_id} для повторного розыгрыша"
|
||||
|
||||
await message.answer(text, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@router.message(Command("redraw"))
|
||||
async def redraw_lottery(message: Message):
|
||||
"""
|
||||
Переиграть розыгрыш для неподтвержденных выигрышей
|
||||
Формат: /redraw <lottery_id>
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
parts = message.text.split()
|
||||
if len(parts) != 2:
|
||||
await message.answer(
|
||||
"❌ Неверный формат команды\n\n"
|
||||
"Используйте: /redraw <lottery_id>"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
lottery_id = int(parts[1])
|
||||
except ValueError:
|
||||
await message.answer("❌ lottery_id должен быть числом")
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy.orm import selectinload
|
||||
from src.core.models import Lottery
|
||||
|
||||
# Загружаем розыгрыш с участниками
|
||||
lottery_result = await session.execute(
|
||||
select(Lottery)
|
||||
.options(selectinload(Lottery.participations))
|
||||
.where(Lottery.id == lottery_id)
|
||||
)
|
||||
lottery = lottery_result.scalar_one_or_none()
|
||||
|
||||
if not lottery:
|
||||
await message.answer(f"❌ Розыгрыш #{lottery_id} не найден")
|
||||
return
|
||||
|
||||
winners = await LotteryService.get_winners(session, lottery_id)
|
||||
|
||||
# Находим неподтвержденные выигрыши старше 24 часов
|
||||
now = datetime.now(timezone.utc)
|
||||
unclaimed_winners = []
|
||||
|
||||
for winner in winners:
|
||||
if not winner.is_claimed and winner.is_notified:
|
||||
time_passed = now - winner.created_at
|
||||
if time_passed.total_seconds() > 24 * 3600:
|
||||
unclaimed_winners.append(winner)
|
||||
|
||||
if not unclaimed_winners:
|
||||
await message.answer(
|
||||
"✅ Нет неподтвержденных выигрышей старше 24 часов.\n"
|
||||
"Повторный розыгрыш не требуется."
|
||||
)
|
||||
return
|
||||
|
||||
# Получаем всех участников, исключая текущих победителей
|
||||
all_participants = []
|
||||
current_winner_accounts = set()
|
||||
|
||||
for winner in winners:
|
||||
if winner.account_number:
|
||||
current_winner_accounts.add(winner.account_number)
|
||||
|
||||
for p in lottery.participations:
|
||||
if p.account_number and p.account_number not in current_winner_accounts:
|
||||
all_participants.append(p)
|
||||
|
||||
if not all_participants:
|
||||
await message.answer(
|
||||
"❌ Нет доступных участников для повторного розыгрыша.\n"
|
||||
"Все участники уже являются победителями."
|
||||
)
|
||||
return
|
||||
|
||||
# Переигрываем каждое неподтвержденное место
|
||||
redraw_results = []
|
||||
|
||||
for old_winner in unclaimed_winners:
|
||||
if not all_participants:
|
||||
break
|
||||
|
||||
# Выбираем нового победителя
|
||||
new_participant = random.choice(all_participants)
|
||||
all_participants.remove(new_participant)
|
||||
|
||||
# Удаляем старого победителя
|
||||
await session.delete(old_winner)
|
||||
|
||||
# Создаем нового победителя
|
||||
new_winner = Winner(
|
||||
lottery_id=lottery_id,
|
||||
user_id=None,
|
||||
account_number=new_participant.account_number,
|
||||
account_id=new_participant.account_id,
|
||||
place=old_winner.place,
|
||||
prize=old_winner.prize,
|
||||
is_manual=False,
|
||||
is_notified=False,
|
||||
is_claimed=False
|
||||
)
|
||||
session.add(new_winner)
|
||||
|
||||
redraw_results.append({
|
||||
'place': old_winner.place,
|
||||
'prize': old_winner.prize,
|
||||
'old_account': old_winner.account_number,
|
||||
'new_account': new_participant.account_number
|
||||
})
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Отправляем уведомления новым победителям
|
||||
for result in redraw_results:
|
||||
# Находим нового победителя
|
||||
new_winner_result = await session.execute(
|
||||
select(Winner).where(
|
||||
and_(
|
||||
Winner.lottery_id == lottery_id,
|
||||
Winner.place == result['place'],
|
||||
Winner.account_number == result['new_account']
|
||||
)
|
||||
)
|
||||
)
|
||||
new_winner = new_winner_result.scalar_one_or_none()
|
||||
|
||||
if new_winner:
|
||||
# Отправляем уведомление новому победителю
|
||||
owner = await AccountService.get_account_owner(session, new_winner.account_number)
|
||||
|
||||
if owner and owner.telegram_id:
|
||||
# Создаем токен верификации
|
||||
await WinnerNotificationService.create_verification_token(
|
||||
session,
|
||||
new_winner.id
|
||||
)
|
||||
|
||||
# Формируем сообщение
|
||||
notification_message = (
|
||||
f"🎉 Поздравляем! Ваш счет выиграл!\n\n"
|
||||
f"🎯 Розыгрыш: {lottery.title}\n"
|
||||
f"🏆 Место: {new_winner.place}\n"
|
||||
f"🎁 Приз: {new_winner.prize}\n"
|
||||
f"💳 Счет: {new_winner.account_number}\n\n"
|
||||
f"⏰ **У вас есть 24 часа для подтверждения!**\n\n"
|
||||
f"Нажмите кнопку ниже, чтобы подтвердить получение приза."
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="✅ Подтвердить получение приза",
|
||||
callback_data=f"confirm_win_{new_winner.id}"
|
||||
)]
|
||||
])
|
||||
|
||||
try:
|
||||
await message.bot.send_message(
|
||||
owner.telegram_id,
|
||||
notification_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
new_winner.is_notified = True
|
||||
await session.commit()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Формируем отчет для админа
|
||||
text = f"🔄 **Повторный розыгрыш завершен!**\n\n"
|
||||
text += f"🎯 Розыгрыш: {lottery.title}\n"
|
||||
text += f"📊 Переиграно мест: {len(redraw_results)}\n\n"
|
||||
|
||||
for result in redraw_results:
|
||||
text += f"🏆 {result['place']} место - {result['prize']}\n"
|
||||
text += f" ❌ Было: {result['old_account']}\n"
|
||||
text += f" ✅ Стало: {result['new_account']}\n\n"
|
||||
|
||||
text += "📨 Новым победителям отправлены уведомления"
|
||||
|
||||
await message.answer(text, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
150
src/handlers/registration_handlers.py
Normal file
150
src/handlers/registration_handlers.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Обработчики для регистрации пользователей"""
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.registration_services import RegistrationService, AccountService
|
||||
from src.core.services import UserService
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class RegistrationStates(StatesGroup):
|
||||
"""Состояния для процесса регистрации"""
|
||||
waiting_for_club_card = State()
|
||||
waiting_for_phone = State()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "start_registration")
|
||||
async def start_registration(callback: CallbackQuery, state: FSMContext):
|
||||
"""Начать процесс регистрации"""
|
||||
text = (
|
||||
"📝 Регистрация в системе\n\n"
|
||||
"Для участия в розыгрышах необходимо зарегистрироваться.\n\n"
|
||||
"Введите номер вашей клубной карты:"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]
|
||||
])
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_club_card)
|
||||
|
||||
|
||||
@router.message(StateFilter(RegistrationStates.waiting_for_club_card))
|
||||
async def process_club_card(message: Message, state: FSMContext):
|
||||
"""Обработка номера клубной карты"""
|
||||
club_card_number = message.text.strip()
|
||||
|
||||
# Проверяем, не занята ли карта
|
||||
async with async_session_maker() as session:
|
||||
existing_user = await RegistrationService.get_user_by_club_card(session, club_card_number)
|
||||
|
||||
if existing_user:
|
||||
await message.answer(
|
||||
f"❌ Клубная карта {club_card_number} уже зарегистрирована.\n\n"
|
||||
"Если это ваша карта, обратитесь к администратору."
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await state.update_data(club_card_number=club_card_number)
|
||||
|
||||
await message.answer(
|
||||
"📱 Теперь введите ваш номер телефона\n"
|
||||
"(или отправьте '-' чтобы пропустить):"
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_phone)
|
||||
|
||||
|
||||
@router.message(StateFilter(RegistrationStates.waiting_for_phone))
|
||||
async def process_phone(message: Message, state: FSMContext):
|
||||
"""Обработка номера телефона"""
|
||||
phone = None if message.text.strip() == "-" else message.text.strip()
|
||||
|
||||
data = await state.get_data()
|
||||
club_card_number = data['club_card_number']
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
user = await RegistrationService.register_user(
|
||||
session,
|
||||
telegram_id=message.from_user.id,
|
||||
club_card_number=club_card_number,
|
||||
phone=phone
|
||||
)
|
||||
|
||||
text = (
|
||||
"✅ Регистрация завершена!\n\n"
|
||||
f"🎫 Клубная карта: {user.club_card_number}\n"
|
||||
f"🔑 Ваш код верификации: **{user.verification_code}**\n\n"
|
||||
"⚠️ Сохраните этот код! Он понадобится для подтверждения выигрыша.\n\n"
|
||||
"Теперь вы можете участвовать в розыгрышах!"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="Markdown")
|
||||
await state.clear()
|
||||
|
||||
except ValueError as e:
|
||||
await message.answer(f"❌ Ошибка регистрации: {str(e)}")
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Произошла ошибка: {str(e)}")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(Command("my_code"))
|
||||
async def show_verification_code(message: Message):
|
||||
"""Показать код верификации пользователя"""
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
if not user or not user.is_registered:
|
||||
await message.answer(
|
||||
"❌ Вы не зарегистрированы в системе.\n\n"
|
||||
"Для регистрации отправьте /start и выберите 'Регистрация'"
|
||||
)
|
||||
return
|
||||
|
||||
text = (
|
||||
"🔑 Ваш код верификации:\n\n"
|
||||
f"**{user.verification_code}**\n\n"
|
||||
"Этот код используется для подтверждения выигрыша.\n"
|
||||
"Сообщите его администратору при получении приза."
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="Markdown")
|
||||
|
||||
|
||||
@router.message(Command("my_accounts"))
|
||||
async def show_user_accounts(message: Message):
|
||||
"""Показать счета пользователя"""
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
if not user or not user.is_registered:
|
||||
await message.answer("❌ Вы не зарегистрированы в системе")
|
||||
return
|
||||
|
||||
accounts = await AccountService.get_user_accounts(session, user.id)
|
||||
|
||||
if not accounts:
|
||||
await message.answer(
|
||||
"У вас пока нет привязанных счетов.\n\n"
|
||||
"Счета добавляются администратором."
|
||||
)
|
||||
return
|
||||
|
||||
text = f"💳 Ваши счета (Клубная карта: {user.club_card_number}):\n\n"
|
||||
|
||||
for i, account in enumerate(accounts, 1):
|
||||
status = "✅" if account.is_active else "❌"
|
||||
text += f"{i}. {status} {account.account_number}\n"
|
||||
|
||||
await message.answer(text)
|
||||
Reference in New Issue
Block a user