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` для аудита действий администраторов.
|