Отдельный реальный AI-прогон: код до / после, 8 тестов и квитанция ↗

РАЗБОР КОДА · PYTHON + SQL

Ошибки не в синтаксисе.
Ошибки между шагами.

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

3 воспроизводимых дефекта10 проверок пройденоРеальная SQLite и конкуренция

Специально написанный исполняемый стенд, не клиентский production-код. Это технический разбор с тестами, а не результат запуска AI-ревью Null. Снимки реального GitLab показаны отдельно.

01

НАДЕЖНОСТЬ ПЛАТЕЖЕЙ

Подпись верна. Деньги зачислены дважды.

Провайдер повторяет callback, потому что не получил ответ. Первая попытка уже изменила баланс, но упала до записи идентификатора события. Проверка «видели ли мы event_id» не спасает.

  1. 1Подписанный callback: +1 000
  2. 2Баланс обновлен, запись события еще не создана
  3. 3Сбой между операциями
  4. 4Повтор того же события: еще +1 000
ЧТО ЗАМЕТИТЬ НА РЕВЬЮ

Идемпотентность должна быть частью той же транзакции, что и зачисление. Отдельная проверка event_id оставляет окно сбоя.

До исправления

UPDATE accounts SET balance_minor = balance_minor + ?;
# Здесь баланс уже закоммичен.
# Сбой до INSERT оставляет платеж без следа в ledger.
INSERT INTO ledger (...);
Полная функция callback_before
def callback_before(self, tenant, raw, signature, *, crash=False):
    """Defect: autocommit splits balance and deduplication into two commits."""
    event = authenticate(raw, signature, self.secret)
    db = self.connect()
    try:
        self.account(db, tenant, event.account_id)
        seen = db.execute(
            "SELECT 1 FROM ledger WHERE tenant=? AND operation_id=?",
            (tenant, event.event_id),
        ).fetchone()
        if seen:
            return "duplicate"
        db.execute(
            "UPDATE accounts SET balance_minor=balance_minor+? "
            "WHERE tenant=? AND account_id=?",
            (event.amount_minor, tenant, event.account_id),
        )
        if crash:
            raise CrashAfterCredit()
        db.execute("INSERT INTO ledger VALUES (?, ?, ?, ?)",
                   (tenant, event.event_id, event.account_id, event.amount_minor))
        return "credited"
    finally:
        db.close()

После исправления

BEGIN IMMEDIATE;
-- Проверить tenant, event_id и привязку payload.
INSERT INTO ledger (...);
UPDATE accounts SET balance_minor = balance_minor + ?;
COMMIT;  -- Либо обе записи, либо ни одной.
Полная функция callback_after
def callback_after(self, tenant, raw, signature, *, crash=False):
    """Fix: event reservation and balance update share one durable transaction."""
    event = authenticate(raw, signature, self.secret)
    db = self.connect()
    try:
        db.execute("BEGIN IMMEDIATE")
        self.account(db, tenant, event.account_id)
        prior = db.execute(
            "SELECT account_id, amount_minor FROM ledger "
            "WHERE tenant=? AND operation_id=?",
            (tenant, event.event_id),
        ).fetchone()
        if prior:
            if tuple(prior) != (event.account_id, event.amount_minor):
                raise Rejected("event id reused with different payload")
            db.commit()
            return "duplicate"
        db.execute("INSERT INTO ledger VALUES (?, ?, ?, ?)",
                   (tenant, event.event_id, event.account_id, event.amount_minor))
        db.execute(
            "UPDATE accounts SET balance_minor=balance_minor+? "
            "WHERE tenant=? AND account_id=?",
            (event.amount_minor, tenant, event.account_id),
        )
        if crash:
            raise CrashAfterCredit()
        db.commit()
        return "credited"
    except BaseException:
        db.rollback()
        raise
    finally:
        db.close()

Резервируем событие и меняем баланс внутри BEGIN IMMEDIATE. Ошибка откатывает оба изменения. Повтор с тем же ID, но другой суммой отклоняется.

До12 000
После11 000

остаток при исходных 10 000 и одном пополнении на 1 000. Числа из тестового сценария, в минимальных единицах валюты.

Проверено тестом

Сбой откатывает баланс. После повтора одно зачисление. Из восьми одновременных обработчиков один возвращает credited, семь duplicate. Измененная сумма и неверная подпись отклоняются.

Открыть исходник тестов ↓
02

КОНКУРЕНТНЫЕ ОПЕРАЦИИ

Оба запроса проверили баланс. Оба ошиблись.

На счете 10 000. Два запроса одновременно хотят зарезервировать по 8 000. Каждый видит достаточный баланс до того, как другой успеет его изменить.

  1. 1Запрос A читает 10 000
  2. 2Запрос B читает те же 10 000
  3. 3A списывает 8 000
  4. 4B тоже списывает 8 000
ЧТО ЗАМЕТИТЬ НА РЕВЬЮ

Проверка balance < amount и последующий UPDATE не являются одним действием. Между ними другой запрос может потратить тот же остаток.

До исправления

balance = SELECT balance_minor FROM accounts ...
if balance >= amount:
    # Другой запрос может пройти ту же проверку.
    UPDATE accounts SET balance_minor = balance_minor - ?
Полная функция withdraw_before
def withdraw_before(self, tenant, account_id, amount, operation_id, *, barrier):
    """Defect: two requests can both pass the balance check."""
    db = self.connect()
    try:
        balance = self.account(db, tenant, account_id)["balance_minor"]
        if balance < amount:
            raise Rejected("insufficient balance")
        barrier.wait(timeout=5)
        db.execute(
            "UPDATE accounts SET balance_minor=balance_minor-? "
            "WHERE tenant=? AND account_id=?", (amount, tenant, account_id),
        )
        db.execute("INSERT INTO ledger VALUES (?, ?, ?, ?)",
                   (tenant, operation_id, account_id, -amount))
        return "reserved"
    finally:
        db.close()

После исправления

UPDATE accounts
SET balance_minor = balance_minor - ?
WHERE tenant = ? AND account_id = ?
  AND balance_minor >= ?;
-- rowcount != 1: отказ, транзакция откатывается.
Полная функция withdraw_after
def withdraw_after(self, tenant, account_id, amount, operation_id):
    """Fix: guarded debit and ledger entry are atomic; retries are bound."""
    if type(amount) is not int or not 0 < amount <= 10**9:
        raise Rejected("invalid amount")
    db = self.connect()
    try:
        db.execute("BEGIN IMMEDIATE")
        prior = db.execute(
            "SELECT account_id, amount_minor FROM ledger "
            "WHERE tenant=? AND operation_id=?", (tenant, operation_id),
        ).fetchone()
        if prior:
            if tuple(prior) != (account_id, -amount):
                raise Rejected("operation id reused with different payload")
            db.commit()
            return "duplicate"
        updated = db.execute(
            "UPDATE accounts SET balance_minor=balance_minor-? "
            "WHERE tenant=? AND account_id=? AND balance_minor>=?",
            (amount, tenant, account_id, amount),
        )
        if updated.rowcount != 1:
            raise Rejected("account unavailable or insufficient balance")
        db.execute("INSERT INTO ledger VALUES (?, ?, ?, ?)",
                   (tenant, operation_id, account_id, -amount))
        db.commit()
        return "reserved"
    except BaseException:
        db.rollback()
        raise
    finally:
        db.close()

Проверка достаточности входит в WHERE самого UPDATE. Изменение баланса и запись в ledger объединены транзакцией. Второй запрос получает отказ, повтор первого не списывает снова.

До−6 000
После2 000

остаток после двух конкурентных запросов по 8 000. Числа из тестового сценария, в минимальных единицах валюты.

Проверено тестом

Тест с барьером воспроизводит двойной расход в исходном коде. Исправленный вариант резервирует только 8 000: один успех, один отказ, остаток 2 000 и одна запись ledger.

Открыть исходник тестов ↓
03

ИЗОЛЯЦИЯ КОМПАНИЙ

Вход выполнен. Но данные принадлежат другой компании.

Пользователь alpha запрашивает выписку по известному идентификатору счета beta. Сессия проверена, однако запрос в базу фильтрует только account_id.

  1. 1Пользователь работает в alpha
  2. 2В запросе указан wallet-b
  3. 3SQL забывает условие tenant
  4. 4В ответе оказывается операция beta
ЧТО ЗАМЕТИТЬ НА РЕВЬЮ

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

До исправления

SELECT * FROM ledger
WHERE account_id = ?;
-- tenant есть в функции, но отсутствует в SQL.
Полная функция statement_before
def statement_before(self, tenant, account_id):
    """Defect: being logged into a tenant does not scope the SQL query."""
    db = self.connect()
    try:
        return [dict(row) for row in db.execute(
            "SELECT * FROM ledger WHERE account_id=?", (account_id,),
        )]
    finally:
        db.close()

После исправления

self.account(db, tenant, account_id)  # Проверка владельца.
SELECT operation_id, amount_minor FROM ledger
WHERE tenant = ? AND account_id = ?;
Полная функция statement_after
def statement_after(self, tenant, account_id):
    """Fix: tenant comes from trusted authentication, never request JSON."""
    db = self.connect()
    try:
        self.account(db, tenant, account_id)
        return [dict(row) for row in db.execute(
            "SELECT operation_id, amount_minor FROM ledger "
            "WHERE tenant=? AND account_id=?", (tenant, account_id),
        )]
    finally:
        db.close()

Проверяем пару tenant + account_id и используем ту же пару в SELECT. Tenant приходит из доверенного контекста входа, а не из JSON запроса.

ДоДанные beta
ПослеОтказ

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

Проверено тестом

Исходный метод возвращает операцию beta пользователю alpha. Исправленный отклоняет чужую выписку и callback; собственная выписка beta остается доступной.

Открыть исходник тестов ↓

ПРОВЕРИТЬ САМОСТОЯТЕЛЬНО

Код можно запустить.

Python 3.10+ и стандартная библиотека. Тесты создают временную базу, воспроизводят дефекты и проверяют исправления. Внешние сервисы и реальные платежи не используются.

python3 -W error::ResourceWarning -m unittest -v test_payments.py

10 тестов включают проверки ошибочного поведения версий before. Успешный тест подтверждает воспроизведение дефекта, а не корректность этих версий.

Исходники, тесты и инструкция ↓

Проверено 27.10.2026, Python 3.14.6. Сбой внедряется исключением; это не тест аварийного завершения процесса. Вывод средств здесь означает резерв в локальном ledger, а не банковский перевод. Хеши исходников и результат проверки · Границы стенда

Какие риски есть в вашем коде?

Выберем закрытые MR и проверим Null на известных дефектах. Пользу модели измерим отдельно от корректности демонстрационного стенда.

Обсудить ретро-прогон ↗
Ø