25.09.2026 Инженерное обновление RU

Сервис дипломов: 27 дефектов после ревизии и почему худшие из них молчали

Ревизия кода и сверка с реальными входными данными дали 27 дефектов. Худший класс — молчаливые: студент выпадал из XML без ошибки. Разбор того, как тихие пропуски стали явными ошибками, почему исправление дат из прошлого разбора само оказалось дефектом и как правило для многосеместровых дисциплин сначала решили, а затем записали в код.

Никита Конкин

Проект Репозиторий Демо
Языковые версии: RU EN

Прошлый разбор был посвящён ошибкам на границах сервисов: как содержательный отказ перестал превращаться в «500». Он касался случаев, когда система знает, что не справилась. Это обновление — о противоположных случаях: когда система не справляется и об этом не знает.

Ревизия и сверка

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

Приоритеты заданы по последствиям, а не по сложности исправления:

  • P0 — в документ об образовании попадают неверные или неполные данные либо утекают персональные данные;
  • P1 — сбой, небезопасное поведение или непонятная ошибка на реальных входных данных;
  • P2 — неточность в редких случаях, неудобство интерфейса.

Показательно, какие дефекты оказались в P0. Почти все они объединены одним свойством: система выдавала результат, не сообщая, что он неполон.

Студент, который выпадал молча

Главный из них выглядел так. Если студента из файла сведений не удавалось найти среди колонок сводной таблицы, генератор писал предупреждение в журнал и переходил к следующему. XML выдавался без ошибки — просто без этого студента.

Предупреждение в журнале здесь хуже, чем ничего: оно создаёт видимость, что сбой учтён, тогда как его никто не увидит. Документ при этом уходит неполным.

Теперь пропуск невозможен. Все проблемы исходных данных собираются в одно исключение и возвращаются вместе:

class DataValidationError(ValueError):
    """Problems in the uploaded data, reported together."""

    def __init__(self, problems: List[str]):
        self.problems = problems
        super().__init__(
            f'Найдено ошибок в исходных данных: {len(problems)}\n'
            + '\n'.join(f'- {problem}' for problem in problems)
        )

Ответ — один запрос с кодом 400, и страница показывает его списком. Сбор всех ошибок сразу, а не остановка на первой, существенен: проверяющий исправляет файл за один проход, а не за столько, сколько в нём ошибок.

Фамилия, инициалы и буквы-двойники

Выпадение студента было следствием, а причина лежала в сопоставлении. Студент искался среди колонок сводной только по фамилии — однофамильцы неразличимы. Кроме того, ФИО, набранное со случайной латиницей, не совпадало с тем же ФИО на кириллице: латинская «А» и кириллическая «А» выглядят одинаково, но являются разными символами.

Сопоставление теперь идёт по фамилии и инициалам, без учёта регистра, с отождествлением «ё» и «е» и с приведением латинских двойников к кириллице:

LOOKALIKES = str.maketrans('AaBCcEeHKMOoPpTXxYyËë', 'АаВСсЕеНКМОоРрТХхУуЁё')

Результат сопоставления проверяется в обе стороны. Если для студента нет колонки — ошибка «нет колонки с оценками». Если подходит несколько — ошибка с их перечнем, а не выбор первой попавшейся. И если на одну колонку претендуют два студента, это тоже ошибка: без этой проверки двое получили бы одни и те же оценки.

Никаких nan в документе

Пустая ячейка таблицы в pandas становится nan, пустая дата — NaT, и эти значения попадали в XML как текст. Теперь они не записываются никогда. Перед выгрузкой проверяются обязательные поля, даты, четырёхзначность года и оценки: допустимы только коды от 2 до 7.

GRADE_CODES = {2, 3, 4, 5, 6, 7}

Отдельно помечаются ФИО со смешением латиницы и кириллицы внутри одного слова — признак опечатки, которую человек не увидит глазами.

Исправление, которое само было дефектом

Здесь необходимо исправить предыдущий разбор. В нём функция date_only была приведена как решение проблемы дат — срез времени и отказ на неразбираемом значении:

parsed = pd.to_datetime(value, errors="coerce", dayfirst=True)

Параметр dayfirst=True нужен, чтобы дата «03.02.2001» читалась как 3 февраля, а не как 2 марта. Но он действует и на дату в формате ISO: строка «2001-02-03» тоже читалась с перестановкой — как 2 марта.

Тест, приведённый в том разборе, этого не обнаружил, и причина поучительна. Он передавал значение pd.Timestamp("2001-02-03 14:25:59") — уже готовый объект даты, на который dayfirst не влияет. Ошибочным был путь текстовой даты, а тест проверял другой путь. Тест был зелёным и при этом не касался дефекта.

Теперь текстовая дата разбирается только по явно заданным форматам:

DATE_TEXT_FORMATS = (
    (re.compile(r'^\d{1,2}\.\d{1,2}\.\d{4}$'), '%d.%m.%Y'),
    (re.compile(r'^\d{4}-\d{2}-\d{2}$'), '%Y-%m-%d'),
    (re.compile(r'^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$'), 'ISO8601'),
)

Формат определяется по виду строки, а не угадывается. Дата, записанная в ячейке числом, теперь отклоняется с просьбой установить для ячейки формат «Дата» — вместо того чтобы быть молча истолкованной.

Граница шлюза

Вторая группа дефектов касалась шлюза, принимающего файлы. В нём было два самописных разборщика multipart и два HTTP-клиента — и ни одного ограничения размера. Теперь разборщик один, и ограничение проверяется до чтения тела запроса: при превышении MAX_UPLOAD_MB (по умолчанию 20 МБ) возвращается 413, а не расходуется память на чтение.

Одна деталь стоила отдельного внимания. Заголовки частей формы теперь читаются как UTF-8: браузеры отправляют кириллические имена файлов как есть, а стандартный разборщик фреймворка такие имена отвергает.

Python-сервисы больше не публикуют свои порты и не принимают запросы из браузера: CORS отключён, и обращаться к ним может только шлюз. Вместе с этим удалены неиспользуемый эндпоинт /config, эндпоинт /validate и отладочный код. Добавлено чтение .xls наряду с .xlsx, причём способ чтения выбирается по содержимому файла, а не по расширению.

Персональные данные

Отдельный приоритет P0 выделен для утечки персональных данных. Журналы теперь содержат номера строк, а не фамилии: для поиска ошибки номера достаточно, а фамилия в журнале — это копия персональных данных, живущая дольше, чем нужно. Таблицы со сведениями о студентах исключены из репозитория правилом: отслеживаются только тестовые наборы данных.

Вопрос правила

Самым поучительным оказался дефект, который не был техническим. Дисциплина, которая шла несколько семестров, занимает в ведомости несколько строк — со своими часами и формой контроля. Парсер переводил в зачётные единицы каждую строку отдельно, и в сводную попадала одна из них вместе со своей оценкой: 72 часа с зачётом и 108 часов с экзаменом превращались в «2 з.е., зачтено».

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

Решение принято, и теперь правило записано в коде. Строки семестров одной дисциплины объединяются; зачётные единицы — это часы всех её строк, включая курсовую работу, делённые на 36 с округлением; итоговая оценка — последняя экзаменационная, а если экзамена не было — последняя полученная.

def credits_of(hours: float) -> int:
    """Credits for hours; 106 h of a 3-credit discipline round to 3."""
    return int(hours / HOURS_PER_CREDIT + 0.5)

Правило по часам сверено с учебными планами: для 89 дисциплин двух групп 2025 года оно совпало с планом во всех случаях. Но совпадение на выборке — ещё не гарантия, поэтому источником истины сделан сам учебный план. Его можно загрузить третьим файлом, и тогда зачётные единицы берутся из его колонки «Всего». Если плана нет или дисциплина в нём не нашлась, остаются единицы по часам ведомости, а строка подсвечивается оранжевым. Новый лист «Проверка з.е.» перечисляет для каждой строки источник единиц и то, что стоит проверить: расхождение с планом, дробные единицы в плане, часы, не кратные 36, семестр без оценки.

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

Дальше

Параллельно ревизии подготовлен черновик архитектуры открытой замены «КиберДиплома» — модульный монолит с локальным веб-интерфейсом, одна сборка для одиночного ПК и сервера, семь зафиксированных архитектурных решений. Ему будет посвящён отдельный разбор.

Выводы

  1. Худший дефект — не тот, что падает, а тот, что молча выдаёт неполный результат. Падение видно; неполный документ — нет.
  2. Предупреждение в журнале не заменяет ошибку: оно фиксирует сбой там, где его никто не прочтёт.
  3. Все ошибки входных данных стоит собирать сразу. Остановка на первой превращает исправление файла в цикл по числу ошибок.
  4. Зелёный тест доказывает только то, что проверяет. Тест даты на готовом объекте ничего не говорил о разборе текста.
  5. Вопрос правила нельзя решать кодом: сначала решение владельца процесса, затем реализация — и сверка с первоисточником, а не только с собственной арифметикой.

Доступность

Проект открыт под лицензией MIT; перечень дефектов и черновик архитектуры находятся в каталоге docs/ репозитория.

Теги

программирование python java валидация безопасность персональные-данные

Другие обновления по проекту

26.09.2026 Технический разбор Сервис автоматизации приложений к дипломам

Открытая замена «КиберДиплома»: архитектура, которую задала Windows 7

Черновик архитектуры открытой системы для подготовки, проверки и печати дипломов. Почему требования к платформам отсеяли почти все технологии, как одна сборка работает и на комп...

27.08.2026 Инженерное обновление Сервис автоматизации приложений к дипломам

Ошибка вместо «500»: как сервис научился объяснять, что пошло не так

Обновление про диагностику: сквозная передача ошибок между сервисами, журнал каждого запроса, логи, переживающие перезапуск контейнера, и первый автоматический тест-раннер.