Улучшение системы оспариваний и исправления
- Оспаривания теперь требуют решения админа после 24ч голосования - Можно повторно оспаривать после разрешённых споров - Исправлены бонусные очки при перепрохождении после оспаривания - Сброс серии при невалидном пруфе - Колесо показывает только доступные игры - Rate limiting только через backend (RATE_LIMIT_ENABLED)
This commit is contained in:
381
docs/disputes.md
Normal file
381
docs/disputes.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# Система оспаривания (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` для аудита действий администраторов.
|
||||
Reference in New Issue
Block a user