Если баллы за тест в LMS начисляются дважды, нельзя ограничиваться ручным уменьшением результата ученика. Дубль обычно означает, что одна попытка несколько раз прошла через обработчик, либо итоговый запрос умножил строки при подсчете.

Сначала нужно определить источник истины: отдельную попытку теста, ответы пользователя, журнал начислений или итог курса. Без этого правка может исправить цифру на экране, но оставить двойные сертификаты, рейтинги, достижения и отчеты.

Уточните, где именно появляется двойной результат

Сравните один проблемный тест на всех уровнях: запрос браузера, запись попытки, таблицу ответов, журнал баллов и итоговый отчет.

  • В базе создано две попытки для одного прохождения теста.
  • Попытка одна, но в журнале начислений присутствуют две одинаковые операции.
  • В базе хранится правильный балл, а отчет или рейтинг показывает удвоенную сумму.
  • Дубль возникает только после повторной проверки, изменения ответа или перезагрузки страницы.
  • Проблема появляется у всех пользователей либо только при медленном соединении и повторном клике.

Не начинайте с массового UPDATE. Сначала сохраните резервную копию и зафиксируйте конкретные user ID, test ID, attempt ID, время отправки и ожидаемое количество баллов.

Почему баллы за тест суммируются дважды

Форма отправляет результат несколько раз

Двойной клик, повторный submit listener, обработчик кнопки вместе с обработчиком формы или повтор браузера после таймаута могут создать два одинаковых HTTP-запроса. Отключение кнопки улучшает интерфейс, но не защищает backend от повторной доставки.

  • Проверьте вкладку Network и идентификатор запроса при одном нажатии.
  • Найдите повторно подключенные listeners после перерисовки компонента.
  • Проверьте автоматический retry в HTTP-клиенте, service worker или мобильном приложении.
  • Убедитесь, что refresh страницы не повторяет предыдущий POST.

Очередь или webhook повторяет задачу

Многие очереди работают по модели at least once: если worker сохранил баллы, но не успел подтвердить задачу, она будет выполнена повторно. Отключать retry нельзя — обработчик должен безопасно принимать один и тот же event несколько раз.

  • Сопоставьте event ID, job ID, attempt ID и число запусков worker.
  • Проверьте timeout, visibility timeout и момент acknowledgement.
  • Найдите повторную доставку после рестарта процесса или потери соединения.
  • Проверьте, не отправляют ли одинаковое событие два независимых сервиса.

Два обработчика начисляют одно и то же

Баллы могут добавляться при событии TestSubmitted и повторно при TestGraded. Дубли также создают ORM observer, database trigger, плагин LMS и собственный обработчик, если все считают себя ответственными за финальный результат.

Гонка между параллельными запросами

Схема «проверить, что начисления нет, затем вставить запись» небезопасна без уникального ограничения. Два процесса могут одновременно увидеть отсутствие строки и оба выполнить INSERT или увеличение счетчика.

Ошибка в формуле или SQL-агрегации

Иногда начисление выполнено один раз, но JOIN с ответами, тегами, курсами или попытками умножает строки. После этого SUM(score) возвращает двойной результат. DISTINCT может временно скрыть симптом, но способен удалить законные одинаковые значения.

  • Сравните число строк до JOIN и после каждого присоединения.
  • Проверьте группировку по attempt ID, а не только по пользователю и тесту.
  • Отделите балл одной попытки от суммы всех разрешенных попыток.
  • Уточните правило: лучший результат, последний результат или накопительная сумма.

Пошаговая диагностика дубля

  1. Выберите одну проблемную попытку и сохраните ее user ID, test ID, attempt ID, request ID и точное время.
  2. Воспроизведите тест с открытой вкладкой Network и проверьте количество запросов отправки.
  3. Проследите один request ID через контроллер, событие, очередь, worker и SQL-запись.
  4. Сравните created_at одинаковых начислений: одинаковое время указывает на параллельность, интервал — на retry или отдельное событие.
  5. Проверьте listeners, observers, hooks, triggers и плагины, связанные с submit, grade, complete и certificate.
  6. Пересчитайте ожидаемый score напрямую из ответов без таблиц рейтинга и достижений.
  7. Выполните агрегирующий SQL по этапам и найдите JOIN, после которого число строк увеличивается.
  8. Повторите сценарий двойным кликом, двумя параллельными запросами, рестартом worker и повторной проверкой ответа.

Как спроектировать начисление без дублей

Определите одну завершенную попытку

Попытка должна иметь собственный ID и понятные состояния, например started, submitted, grading и completed. Переход к completed выполняется контролируемо, а повторный запрос возвращает уже рассчитанный результат, не прибавляя его снова.

Добавьте идемпотентный ключ

Для операции начисления используйте уникальный business key: attempt ID вместе с типом операции или стабильный event ID. Уникальное ограничение базы должно физически запрещать вторую такую запись, даже если два worker стартовали одновременно.

  • Ключ формируется сервером и не зависит от повторного клика пользователя.
  • Повтор с тем же ключом возвращает первоначальный результат.
  • Одинаковый балл за другую разрешенную попытку получает другой attempt ID.
  • Конфликт уникальности обрабатывается как повтор, а не как ошибка всей LMS.

Разделите результат попытки и баланс ученика

Храните рассчитанный score попытки отдельно от журнала бонусных или рейтинговых операций. Итог курса должен вычисляться по явному правилу, а не увеличиваться каждым событием без ссылки на первоисточник.

Как исправить работающую LMS

  1. Остановите повторное начисление для новых попыток, сохранив прием и проверку ответов.
  2. Выберите канонический обработчик, который завершает попытку и публикует одно событие результата.
  3. Добавьте idempotency key и уникальное ограничение на операцию начисления.
  4. Объедините проверку состояния, запись результата и создание ledger entry в одну транзакцию.
  5. Исправьте SQL-агрегацию так, чтобы одна attempt row участвовала в расчете один раз.
  6. Подготовьте выборку исторических дублей и сравните ее с журналами перед коррекцией.
  7. Исправляйте прошлые данные целевым скриптом или компенсирующими операциями, не удаляя весь прогресс пользователя.
  8. После исправления пересчитайте зависимые рейтинги, сертификаты и отчеты из канонических попыток.

Как проверить результат

  • Одинарный и двойной клик создают одну завершенную попытку и одно начисление.
  • Повтор того же HTTP-запроса с idempotency key возвращает прежний результат.
  • Два параллельных worker не могут вставить две операции для одной попытки.
  • Retry после сохранения, но до acknowledgement не увеличивает баллы.
  • Повторная проверка ответа обновляет результат по выбранному правилу, а не добавляет его заново.
  • Вторая разрешенная попытка учитывается как отдельная и корректно применяется к правилу best или last score.
  • Рейтинг, сертификат, прогресс и административный отчет показывают одинаковое значение.
  • Коррекция исторических дублей не затронула законные бонусы и другие тесты.

Типичные ошибки

  • Ограничиться блокировкой кнопки во frontend и не защитить backend.
  • Использовать SELECT перед INSERT без уникального индекса и транзакции.
  • Отключить retry очереди вместо идемпотентной обработки.
  • Применить SUM(DISTINCT score), который объединит разные попытки с одинаковым результатом.
  • Считать user ID и test ID достаточным ключом, если политика разрешает несколько попыток.
  • Удалить все баллы пользователя вместо точной коррекции дублей.
  • Исправить таблицу attempts, но не пересчитать рейтинг, прогресс и сертификаты.
  • Не сохранить резервную копию и список измененных записей перед массовой правкой.

Как предотвратить повторение

  • Присваивайте request ID, attempt ID и event ID на входе и передавайте их через всю цепочку.
  • Храните неизменяемый журнал операций с причиной и ссылкой на попытку.
  • Добавьте database constraints, а не полагайтесь только на условие в приложении.
  • Тестируйте double submit, parallel requests, queue retry и падение worker после commit.
  • Запускайте периодическую сверку attempts, ledger и итоговых балансов.
  • Алертируйте появление двух начислений одного типа для одного attempt ID.
  • Документируйте правила нескольких попыток, пересдачи и ручной перепроверки.

Когда нужна помощь

Если баллы за тест в LMS суммируются дважды, я прослежу одну попытку от браузера до базы, проверю события, очередь, гонки и SQL-агрегацию. Затем добавлю идемпотентное начисление, исправлю исторические дубли и сверю прогресс, рейтинги и сертификаты без удаления законных результатов учеников.