- Оспаривания теперь требуют решения админа после 24ч голосования - Можно повторно оспаривать после разрешённых споров - Исправлены бонусные очки при перепрохождении после оспаривания - Сброс серии при невалидном пруфе - Колесо показывает только доступные игры - Rate limiting только через backend (RATE_LIMIT_ENABLED)
382 lines
19 KiB
Markdown
382 lines
19 KiB
Markdown
# Система оспаривания (Disputes)
|
||
|
||
Система оспаривания позволяет участникам марафона проверять доказательства (пруфы) выполненных заданий друг друга и голосовать за их валидность.
|
||
|
||
## Общий принцип работы
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────────────────┐
|
||
│ ЖИЗНЕННЫЙ ЦИКЛ ДИСПУТА │
|
||
└──────────────────────────────────────────────────────────────────────────┘
|
||
|
||
Участник A Участник B Все участники
|
||
выполняет задание замечает проблему голосуют
|
||
│ │ │
|
||
▼ ▼ ▼
|
||
┌───────────┐ 24 часа ┌───────────┐ 24 часа ┌───────────┐
|
||
│ Завершено │ ─────────────────▶ │ Оспорено │ ─────────────▶ │ Решено │
|
||
│ │ окно оспаривания │ (OPEN) │ голосование │ │
|
||
└───────────┘ └───────────┘ └───────────┘
|
||
│ │ │
|
||
│ │ ├──▶ VALID (пруф OK)
|
||
│ │ │ Задание остаётся
|
||
│ │ │
|
||
│ │ └──▶ INVALID (пруф не OK)
|
||
│ │ Задание возвращается
|
||
│ │
|
||
└──────────────────────────────────┘
|
||
Если не оспорено — задание засчитано
|
||
```
|
||
|
||
## Кто может оспаривать
|
||
|
||
| Условие | Можно оспорить? |
|
||
|---------|-----------------|
|
||
| Своё задание | ❌ Нельзя |
|
||
| Чужое задание (статус COMPLETED) | ✅ Можно (в течение 24 часов) |
|
||
| Чужое задание (статус ACTIVE/DROPPED) | ❌ Нельзя |
|
||
| Прошло более 24 часов с момента выполнения | ❌ Нельзя |
|
||
| Уже есть активный диспут на это задание | ❌ Нельзя |
|
||
|
||
## Типы оспариваемых заданий
|
||
|
||
### 1. Обычные челленджи
|
||
|
||
Можно оспорить выполнение любого челленджа. При признании пруфа невалидным:
|
||
- Задание переходит в статус `RETURNED`
|
||
- Очки снимаются с участника
|
||
- Участник должен переделать задание
|
||
|
||
### 2. Прохождения игр (Playthrough)
|
||
|
||
Основное задание прохождения можно оспорить. При признании невалидным:
|
||
- Основное задание переходит в статус `RETURNED`
|
||
- Очки снимаются
|
||
- **Все бонусные челленджи сбрасываются** в статус `PENDING`
|
||
|
||
### 3. Бонусные челленджи
|
||
|
||
Каждый бонусный челлендж можно оспорить **отдельно**. При признании невалидным:
|
||
- Только этот бонусный челлендж сбрасывается в `PENDING`
|
||
- Участник может переделать его
|
||
- Основное задание и другие бонусы не затрагиваются
|
||
|
||
**Важно:** Очки за бонусные челленджи начисляются только при завершении основного задания. Поэтому при оспаривании бонуса очки не снимаются — просто сбрасывается статус.
|
||
|
||
## Процесс голосования
|
||
|
||
### Создание диспута
|
||
|
||
1. Участник нажимает "Оспорить" на странице деталей задания
|
||
2. Вводит причину оспаривания (минимум 10 символов)
|
||
3. Создаётся диспут со статусом `OPEN`
|
||
4. Владельцу задания отправляется уведомление в Telegram
|
||
|
||
### Голосование
|
||
|
||
- **Любой участник марафона** может голосовать
|
||
- Два варианта: "Валидно" (пруф OK) или "Невалидно" (пруф не OK)
|
||
- Можно **изменить** свой голос до завершения голосования
|
||
- Голосование длится **24 часа** с момента создания диспута
|
||
|
||
### Комментарии
|
||
|
||
- Участники могут оставлять комментарии для обсуждения
|
||
- Комментарии помогают другим участникам принять решение
|
||
- Комментарии доступны только пока диспут открыт
|
||
|
||
## Разрешение диспута
|
||
|
||
### Автоматическое (по таймеру)
|
||
|
||
Через 24 часа диспут автоматически разрешается:
|
||
- Система подсчитывает голоса
|
||
- При равенстве голосов — **в пользу обвиняемого** (пруф валиден)
|
||
- Результат: `RESOLVED_VALID` или `RESOLVED_INVALID`
|
||
|
||
**Технически:** Фоновый планировщик (`DisputeScheduler`) проверяет истёкшие диспуты каждые 5 минут.
|
||
|
||
### Результаты
|
||
|
||
| Результат | Условие | Последствия |
|
||
|-----------|---------|-------------|
|
||
| `RESOLVED_VALID` | Голосов "валидно" ≥ голосов "невалидно" | Задание остаётся выполненным |
|
||
| `RESOLVED_INVALID` | Голосов "невалидно" > голосов "валидно" | Задание возвращается |
|
||
|
||
### Что происходит при INVALID
|
||
|
||
**Для обычного задания:**
|
||
1. Статус → `RETURNED`
|
||
2. Очки (`points_earned`) вычитаются из общего счёта участника
|
||
3. Пруфы сохраняются для истории
|
||
|
||
**Для прохождения:**
|
||
1. Основное задание → `RETURNED`
|
||
2. Очки вычитаются
|
||
3. Все бонусные челленджи сбрасываются:
|
||
- Статус → `PENDING`
|
||
- Пруфы удаляются
|
||
- Очки обнуляются
|
||
|
||
**Для бонусного челленджа:**
|
||
1. Только этот бонус → `PENDING`
|
||
2. Пруфы удаляются
|
||
3. Можно переделать
|
||
|
||
## API эндпоинты
|
||
|
||
### Создание диспута
|
||
|
||
```
|
||
POST /api/v1/assignments/{assignment_id}/dispute
|
||
POST /api/v1/bonus-assignments/{bonus_id}/dispute
|
||
|
||
Body: { "reason": "Описание проблемы с пруфом..." }
|
||
```
|
||
|
||
### Голосование
|
||
|
||
```
|
||
POST /api/v1/disputes/{dispute_id}/vote
|
||
|
||
Body: { "vote": true } // true = валидно, false = невалидно
|
||
```
|
||
|
||
### Комментарии
|
||
|
||
```
|
||
POST /api/v1/disputes/{dispute_id}/comments
|
||
|
||
Body: { "text": "Текст комментария" }
|
||
```
|
||
|
||
### Получение информации
|
||
|
||
```
|
||
GET /api/v1/assignments/{assignment_id}
|
||
|
||
// В ответе включено поле dispute с полной информацией:
|
||
{
|
||
"dispute": {
|
||
"id": 1,
|
||
"status": "open",
|
||
"reason": "...",
|
||
"votes_valid": 3,
|
||
"votes_invalid": 2,
|
||
"my_vote": true,
|
||
"expires_at": "2024-12-30T12:00:00Z",
|
||
"comments": [...],
|
||
"votes": [...]
|
||
}
|
||
}
|
||
```
|
||
|
||
## Структура базы данных
|
||
|
||
### Таблица `disputes`
|
||
|
||
| Поле | Тип | Описание |
|
||
|------|-----|----------|
|
||
| `id` | INT | PK |
|
||
| `assignment_id` | INT | FK → assignments (nullable для бонусов) |
|
||
| `bonus_assignment_id` | INT | FK → bonus_assignments (nullable для основных) |
|
||
| `raised_by_id` | INT | FK → users |
|
||
| `reason` | TEXT | Причина оспаривания |
|
||
| `status` | VARCHAR(20) | open / valid / invalid |
|
||
| `created_at` | DATETIME | Время создания |
|
||
| `resolved_at` | DATETIME | Время разрешения |
|
||
|
||
**Ограничение:** Либо `assignment_id`, либо `bonus_assignment_id` должен быть заполнен (не оба).
|
||
|
||
### Таблица `dispute_votes`
|
||
|
||
| Поле | Тип | Описание |
|
||
|------|-----|----------|
|
||
| `id` | INT | PK |
|
||
| `dispute_id` | INT | FK → disputes |
|
||
| `user_id` | INT | FK → users |
|
||
| `vote` | BOOLEAN | true = валидно, false = невалидно |
|
||
| `created_at` | DATETIME | Время голоса |
|
||
|
||
**Ограничение:** Один голос на участника (`UNIQUE dispute_id + user_id`).
|
||
|
||
### Таблица `dispute_comments`
|
||
|
||
| Поле | Тип | Описание |
|
||
|------|-----|----------|
|
||
| `id` | INT | PK |
|
||
| `dispute_id` | INT | FK → disputes |
|
||
| `user_id` | INT | FK → users |
|
||
| `text` | TEXT | Текст комментария |
|
||
| `created_at` | DATETIME | Время комментария |
|
||
|
||
## UI компоненты
|
||
|
||
### Кнопка "Оспорить"
|
||
|
||
Появляется на странице деталей задания (`/assignments/{id}`) если:
|
||
- Статус задания: `COMPLETED`
|
||
- Это не своё задание
|
||
- Прошло меньше 24 часов с момента выполнения
|
||
- Нет активного диспута
|
||
|
||
### Секция диспута
|
||
|
||
Показывается если есть активный или завершённый диспут:
|
||
- Статус (открыт / валиден / невалиден)
|
||
- Таймер до окончания (для открытых)
|
||
- Причина оспаривания
|
||
- Кнопки голосования с счётчиками
|
||
- Секция комментариев
|
||
|
||
### Для бонусных челленджей
|
||
|
||
На каждом бонусном челлендже:
|
||
- Маленькая кнопка "Оспорить" (если можно)
|
||
- Бейдж статуса диспута
|
||
- Компактное голосование прямо в карточке бонуса
|
||
|
||
## Уведомления
|
||
|
||
### Telegram уведомления
|
||
|
||
| Событие | Получатель | Сообщение |
|
||
|---------|------------|-----------|
|
||
| Создание диспута | Владелец задания | "Ваше задание X оспорено в марафоне Y" |
|
||
| Результат: валидно | Владелец задания | "Диспут по заданию X решён в вашу пользу" |
|
||
| Результат: невалидно | Владелец задания | "Диспут по заданию X решён не в вашу пользу, задание возвращено" |
|
||
|
||
## Конфигурация
|
||
|
||
```python
|
||
# backend/app/api/v1/assignments.py
|
||
DISPUTE_WINDOW_HOURS = 24 # Окно для создания диспута
|
||
|
||
# backend/app/services/dispute_scheduler.py
|
||
CHECK_INTERVAL_SECONDS = 300 # Проверка каждые 5 минут
|
||
DISPUTE_WINDOW_HOURS = 24 # Время голосования
|
||
```
|
||
|
||
## Пример сценария
|
||
|
||
### Сценарий 1: Успешное оспаривание
|
||
|
||
1. **Иван** выполняет челлендж "Пройти уровень без смертей"
|
||
2. **Иван** прикладывает скриншот финального экрана
|
||
3. **Петр** открывает детали задания и видит, что на скриншоте есть смерти
|
||
4. **Петр** нажимает "Оспорить" и пишет: "На скриншоте видно 3 смерти"
|
||
5. Участники марафона голосуют: 5 за "невалидно", 2 за "валидно"
|
||
6. Через 24 часа диспут закрывается как `RESOLVED_INVALID`
|
||
7. Задание Ивана возвращается, очки снимаются
|
||
8. Иван получает уведомление и должен переделать задание
|
||
|
||
### Сценарий 2: Оспаривание бонуса
|
||
|
||
1. **Анна** проходит игру и выполняет бонусный челлендж
|
||
2. **Сергей** замечает проблему с пруфом бонуса
|
||
3. **Сергей** оспаривает только бонусный челлендж
|
||
4. Голосование: 4 за "невалидно", 1 за "валидно"
|
||
5. Результат: бонус сбрасывается в `PENDING`
|
||
6. Основное задание Анны **не затронуто**
|
||
7. Анна может переделать бонус (пока основное задание активно)
|
||
|
||
## Ручное разрешение диспутов
|
||
|
||
Администраторы системы и организаторы марафонов могут вручную разрешать диспуты, не дожидаясь окончания 24-часового окна голосования.
|
||
|
||
### Кто может разрешать
|
||
|
||
| Роль | Доступ |
|
||
|------|--------|
|
||
| **Системный админ** | Все диспуты во всех марафонах (`/admin/disputes`) |
|
||
| **Организатор марафона** | Только диспуты в своём марафоне (секция "Оспаривания" на странице марафона) |
|
||
|
||
### Интерфейс для системных админов
|
||
|
||
**Путь:** `/admin/disputes`
|
||
|
||
- Отдельная страница в админ-панели
|
||
- Фильтры: "Открытые" / "Все"
|
||
- Показывает диспуты из всех марафонов
|
||
- Информация: марафон, задание, участник, кто оспорил, причина
|
||
- Счётчик голосов и время до истечения
|
||
- Кнопки "Валидно" / "Невалидно" для мгновенного решения
|
||
|
||
### Интерфейс для организаторов
|
||
|
||
**Путь:** На странице марафона (`/marathons/{id}`) → секция "Оспаривания"
|
||
|
||
- Доступна только организаторам активного марафона
|
||
- Показывает только диспуты текущего марафона
|
||
- Компактный вид с возможностью раскрытия
|
||
- Ссылка на страницу задания для детального просмотра
|
||
|
||
### API для ручного разрешения
|
||
|
||
**Системные админы:**
|
||
```
|
||
GET /api/v1/admin/disputes?status_filter=open|all
|
||
POST /api/v1/admin/disputes/{dispute_id}/resolve
|
||
|
||
Body: { "is_valid": true|false }
|
||
```
|
||
|
||
**Организаторы марафона:**
|
||
```
|
||
GET /api/v1/marathons/{marathon_id}/disputes?status_filter=open|all
|
||
POST /api/v1/marathons/{marathon_id}/disputes/{dispute_id}/resolve
|
||
|
||
Body: { "is_valid": true|false }
|
||
```
|
||
|
||
### Что происходит при ручном разрешении
|
||
|
||
Логика идентична автоматическому разрешению:
|
||
|
||
**При `is_valid: true`:**
|
||
- Диспут закрывается как `RESOLVED_VALID`
|
||
- Задание остаётся выполненным
|
||
- Участник получает уведомление
|
||
|
||
**При `is_valid: false`:**
|
||
- Диспут закрывается как `RESOLVED_INVALID`
|
||
- Задание возвращается, очки снимаются
|
||
- Участник получает уведомление
|
||
|
||
### Важно: логика снятия очков за бонусы
|
||
|
||
При отклонении бонусного диспута система проверяет статус основного прохождения:
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ БОНУС ПРИЗНАН НЕВАЛИДНЫМ │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ Основное прохождение Основное прохождение │
|
||
│ НЕ завершено? УЖЕ завершено? │
|
||
│ │ │ │
|
||
│ ▼ ▼ │
|
||
│ ┌───────────┐ ┌───────────┐ │
|
||
│ │ Просто │ │ Вычитаем │ │
|
||
│ │ сбросить │ │ очки из │ │
|
||
│ │ бонус │ │ участника │ │
|
||
│ └───────────┘ └───────────┘ │
|
||
│ (очки ещё не (очки уже были │
|
||
│ были начислены) начислены при │
|
||
│ завершении прохождения) │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Почему так?** Очки за бонусные челленджи начисляются только в момент завершения основного прохождения (чтобы нельзя было получить очки за бонусы и потом дропнуть основное задание).
|
||
|
||
## Логирование действий
|
||
|
||
Ручное разрешение диспутов логируется в системе:
|
||
|
||
| Действие | Тип лога |
|
||
|----------|----------|
|
||
| Админ подтвердил пруф | `DISPUTE_RESOLVE_VALID` |
|
||
| Админ отклонил пруф | `DISPUTE_RESOLVE_INVALID` |
|
||
|
||
Логи доступны в `/admin/logs` для аудита действий администраторов.
|