Files
game-marathon/docs/disputes.md
mamonov.ep 89dbe2c018 Улучшение системы оспариваний и исправления
- Оспаривания теперь требуют решения админа после 24ч голосования
  - Можно повторно оспаривать после разрешённых споров
  - Исправлены бонусные очки при перепрохождении после оспаривания
  - Сброс серии при невалидном пруфе
  - Колесо показывает только доступные игры
  - Rate limiting только через backend (RATE_LIMIT_ENABLED)
2025-12-29 22:23:34 +03:00

382 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Система оспаривания (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` для аудита действий администраторов.