Инженерный кейс
Раздача заказов с учётом долгов
Заказы наёмным исполнителям: выдача, статусы, спорные случаи и расчёт выплат. Один файл, стандартная библиотека и одна внешняя зависимость.
- колонок с балансом
- 0
- внешняя зависимость
- 1
- строк в одном файле
- 4 266
Управляющий собирает заказ по шагам — исполнитель, товар, количество, данные для выдачи, — исполнитель получает его в личку и отмечает выполнение. Бот начисляет ему сумму, ведёт долг перед каждым, замораживает деньги по спорным заказам и считает выплаты. Четыре роли доступа, два языка интерфейса, при желании — HTTP-контракт для внешней панели.
Долга нет ни в одной колонке — он каждый раз считается заново
В схеме нет поля «баланс» или «долг». Есть одна таблица проводок с четырьмя видами записи: начисление, заморозка, возврат, выплата. Долг — это сумма по ней.
Очевидное решение — держать число у исполнителя и править его на каждом событии — ломается здесь быстрее обычного: у одного заказа сумма меняет знак до трёх раз, плюс выплаты частями. Любой сбой между «поправили число» и «записали историю» даёт две разные правды, и спор с исполнителем разрешать нечем — он видит одну цифру, управляющий другую. При пересчёте расхождение невозможно: история и есть баланс.
Цена размена честная: экраны балансов вызывают пересчёт в цикле по каждому исполнителю, а индексов в схеме нет ни одного. На сотнях заказов незаметно, на десятках тысяч — нет.
Смена статуса и деньги — одна операция, а не две подряд
Любой переход заказа выполняется запросом с условием «изменить, только если статус всё ещё прежний», и проводка вставляется лишь тогда, когда переход действительно состоялся, в той же транзакции.
Кнопку в мессенджере нажимают дважды всегда: палец дрогнул, сеть подвисла, обновление пришло повторно. Код вида «прочитать статус → проверить → записать» оставляет между чтением и записью окно, в которое второй поток успевает начислить деньги ещё раз. Здесь второй клик просто получает «ноль изменённых строк» и уходит в ветку «статус уже изменился».
Сетевая ошибка при отправке — это не «не доставлено»
Отправка возвращает не «да/нет», а причину: не нажимал старт, заблокировал бота, текст слишком длинный, ошибка интерфейса, сеть. Последний случай обрабатывается отдельно: заказ остаётся активным, управляющий видит «доставка не подтверждена».
Если мессенджер не ответил, это не значит, что сообщение не ушло — оно могло уже лежать у исполнителя. Пометить такой заказ недоставленным значит подтолкнуть управляющего к кнопке «Повторить» — и выдать один и тот же логин с паролем двум людям. Поэтому неопределённость показывается как неопределённость, а не приводится к ошибке.
Причина недоставки решает, какие кнопки показать
Под сообщением о неудаче собирается клавиатура, и при причине «текст слишком длинный» кнопки «Повторить» в ней нет — остаются «Другой исполнитель» и «Отмена».
Одна кнопка «Повторить» на все случаи выглядит проще. Но длина текста — свойство самого сообщения, а не сбой канала: повтор даст ту же ошибку, и так бесконечно. Кнопка, которая заведомо не сработает, хуже отсутствующей — человек будет жать её, пока не решит, что бот сломан.
Длина текста считается так, как её считает мессенджер
Лимит проверяется не по числу символов Python, а по числу единиц UTF-16 — так, как меряет сам Telegram. Любая эмодзи у него считается за два знака, у Python за один.
Иначе сообщение проходит проверку при вводе и отвергается при отправке — то есть ошибка всплывает не там, где её можно исправить, а на шаг позже и без понятного объяснения.
Приглашение по имени не выдаёт доступ
Добавить человека можно пересланным сообщением, числовым идентификатором или именем @….
Первые два срабатывают сразу. Третий только создаёт заявку: когда человек с таким именем пишет
боту, владелец получает запрос «это точно он?» с числовым идентификатором.
Имя в мессенджере не идентификатор — его можно освободить и занять. Приглашение, выписанное
на @ivan_manager, через месяц активирует совсем другой человек и получит доступ к кассе
и к чужим логинам. Числовой идентификатор сменить нельзя, поэтому окончательное решение всегда
принимается по нему и всегда человеком.
Права проверяются на сервере, а не скрытием кнопок
У модератора урезанное меню, но дело не в нём: на входе стоит белый список разрешённых действий, и неизвестное запрещено по умолчанию. Отдельно проверяются и текстовые режимы ввода.
В мессенджере данные кнопки отправляет клиент, а не сервер. Достаточно один раз увидеть у коллеги сообщение с кнопкой «Выплатить всё» — а он его увидит, уведомления идут всему персоналу — и повторить её содержимое. Скрытие кнопок здесь косметика; решение принимает сервер.
Владельцев из настроек бот не может лишить прав
Роли живут в базе, но владельцы из файла настроек восстанавливаются при каждом запуске, и их роль вообще не читается из базы. Любое действие над ними отбивается — как и попытка сменить роль самому себе.
Без этого получается классическая ловушка: два владельца разжаловали друг друга, и раздел управления доступом недоступен никому — восстановить права можно только правкой базы руками.
Одно действие внешней панели запрещено намеренно
HTTP-контракт переиспользует те же функции, что и бот, но принимает лишь часть переходов. Отметки «выполнено» среди них нет: это действие исполнителя в его мессенджере.
Соблазн выставить наружу все переходы понятен — они уже написаны. Но «выполнил» — единственное событие во всей системе, которое подтверждает исполнитель, и именно оно начисляет деньги. Дай его панели, и подтверждение перестаёт что-либо значить: спор «я этого не делал» станет неразрешимым.
Python 3.12 · pyTelegramBotAPI · SQLite (WAL) · http.server · systemd