From 93da29ce811b86c1d05d06d0c689efa0c957cbdb Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 19:15:23 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BB=D0=B0=D1=82=D0=B5=D0=B6=D0=B5?= =?UTF-8?q?=D0=B9=20277=20=E2=86=92=20279=20(=D1=81=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE=D0=B2=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=D0=B5=D1=80=D0=BE=D0=B2)=20+=20lock=5Ftimeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два параллельных агента взяли ОДИН номер: 277_landing_showcase_runs.sql в ветке витрины и 277_payments_live_checkout_uidx.sql здесь. Обе ветки по отдельности зелёные, но на main второй файл встал бы конфликтом — ровно ловушка из шапки tests/test_migration_numbering.py: номер сверяется с origin/main, а не с чужими открытыми ветками. Заняты сейчас: 275 (метрики), 276+277 (витрина), 278 (публичный токен) → этот 279. Ссылки на номер обновлены в payments.py и test_payments_router.py, включая путь, по которому тест читает предикат частичного UNIQUE. Плюс CREATE UNIQUE INDEX на существующей таблице payments обёрнут в SET LOCAL lock_timeout = '5s' — гейт #2752. --- tradein-mvp/backend/app/api/v1/payments.py | 8 ++++---- ..._uidx.sql => 279_payments_live_checkout_uidx.sql} | 5 ++++- tradein-mvp/backend/tests/test_payments_router.py | 12 ++++++------ 3 files changed, 14 insertions(+), 11 deletions(-) rename tradein-mvp/backend/data/sql/{277_payments_live_checkout_uidx.sql => 279_payments_live_checkout_uidx.sql} (93%) diff --git a/tradein-mvp/backend/app/api/v1/payments.py b/tradein-mvp/backend/app/api/v1/payments.py index 49245969..dc48b658 100644 --- a/tradein-mvp/backend/app/api/v1/payments.py +++ b/tradein-mvp/backend/app/api/v1/payments.py @@ -14,7 +14,7 @@ Merge безопасен на выключенном контуре — на п ── Идемпотентность держится на БД, а не на «проверить-потом-вставить» ──────── Все три гонки, которые здесь реальны (двойной клик по кнопке оплаты; банк шлёт AUTHORIZED и CONFIRMED одновременно; банк ретраит нотификацию почасово сутки), -закрыты UNIQUE-ключами миграций 233 и 277 + `ON CONFLICT DO NOTHING`. Пара +закрыты UNIQUE-ключами миграций 233 и 279 + `ON CONFLICT DO NOTHING`. Пара «SELECT, потом INSERT» здесь была бы дефектом: между ними успевает пройти параллельный запрос, и выдача (или холд на карте) происходит дважды. SELECT живого платежа в `checkout` остался, но только как быстрый путь для честного @@ -157,7 +157,7 @@ _PRE_CONFIRM_STATUSES = frozenset( # после отказа человек вправе попробовать оплатить заново. # # Этот же список — предикат частичного UNIQUE(estimate_id, product_code) -# миграции 277, который и делает «один живой платёж» свойством БД, а не +# миграции 279, который и делает «один живой платёж» свойством БД, а не # порядка выполнения. Расхождение кода и миграции ловит # tests/test_payments_router.py::test_live_status_predicate_matches_code. _REUSABLE_STATUSES = _PRE_CONFIRM_STATUSES @@ -246,7 +246,7 @@ def checkout( """Создаёт платёж и возвращает `PaymentURL` формы Т-Банка. Идемпотентность — свойство БД, а не порядка выполнения: частичный UNIQUE - (estimate_id, product_code) по живым статусам (миграция 277) физически не + (estimate_id, product_code) по живым статусам (миграция 279) физически не даёт существовать двум живым платежам по одной оценке, а `ON CONFLICT DO NOTHING` превращает проигрыш в гонке в ответ, а не во второй `Init` (и, значит, во второй холд на карте покупателя). @@ -291,7 +291,7 @@ def checkout( # Освобождаем пару (estimate_id, product_code) от брошенных попыток ДО # проверки живого платежа: иначе и переиспользование вернуло бы мёртвую - # ссылку, и UNIQUE миграции 277 не дал бы создать новую (см. комментарий у + # ссылку, и UNIQUE миграции 279 не дал бы создать новую (см. комментарий у # _ABANDONED_AFTER_MINUTES). db.execute( text( diff --git a/tradein-mvp/backend/data/sql/277_payments_live_checkout_uidx.sql b/tradein-mvp/backend/data/sql/279_payments_live_checkout_uidx.sql similarity index 93% rename from tradein-mvp/backend/data/sql/277_payments_live_checkout_uidx.sql rename to tradein-mvp/backend/data/sql/279_payments_live_checkout_uidx.sql index 25d1d7b4..50360c69 100644 --- a/tradein-mvp/backend/data/sql/277_payments_live_checkout_uidx.sql +++ b/tradein-mvp/backend/data/sql/279_payments_live_checkout_uidx.sql @@ -1,4 +1,4 @@ --- 277_payments_live_checkout_uidx.sql +-- 279_payments_live_checkout_uidx.sql -- Идемпотентность checkout на уровне БД: не больше одного ЖИВОГО платежа на -- пару (estimate_id, product_code). -- @@ -37,6 +37,9 @@ -- ничью запись. IF NOT EXISTS — повторное применение безвредно. BEGIN; +-- Конвенция проекта (#2752): CREATE UNIQUE INDEX на существующей таблице берёт +-- блокировку и без lock_timeout встанет в очередь за чужой сессией. +SET LOCAL lock_timeout = '5s'; CREATE UNIQUE INDEX IF NOT EXISTS payments_live_estimate_product_uidx ON payments (estimate_id, product_code) diff --git a/tradein-mvp/backend/tests/test_payments_router.py b/tradein-mvp/backend/tests/test_payments_router.py index e85b677c..25031832 100644 --- a/tradein-mvp/backend/tests/test_payments_router.py +++ b/tradein-mvp/backend/tests/test_payments_router.py @@ -83,7 +83,7 @@ class _FakeDb: _NOTIFICATION_KEY = ("tbank_payment_id", "status", "amount_kopecks", "token") _ENTITLEMENT_KEY = ("payment_id", "kind", "ref_id") - # Частичный UNIQUE миграции 277: ключ (estimate_id, product_code), предикат — + # Частичный UNIQUE миграции 279: ключ (estimate_id, product_code), предикат — # «живые» статусы. Переписан здесь ПО МИГРАЦИИ, а не импортирован из # payments.py: иначе тест поехал бы вслед за дефектом. Совпадение списка с # кодом отдельно гейтит test_live_status_predicate_matches_code. @@ -206,7 +206,7 @@ class _FakeDb: return _Result(row) def _insert_payment(self, sql: str, params: dict[str, Any]) -> _Result: - """Ведёт себя как Postgres с частичным UNIQUE миграции 277. + """Ведёт себя как Postgres с частичным UNIQUE миграции 279. Конфликт наступает только когда УЖЕ есть строка с той же парой (estimate_id, product_code) И статусом из предиката — ровно как у @@ -635,7 +635,7 @@ def test_price_matches_published_offer() -> None: def test_live_status_predicate_matches_code() -> None: - """Предикат частичного UNIQUE (277) и `_REUSABLE_STATUSES` — один список. + """Предикат частичного UNIQUE (279) и `_REUSABLE_STATUSES` — один список. Индекс шире кода запрещает легитимную повторную попытку оплаты; индекс уже кода пропускает второй холд на карте. И то и другое — про деньги, поэтому @@ -643,10 +643,10 @@ def test_live_status_predicate_matches_code() -> None: """ from app.api.v1.payments import _REUSABLE_STATUSES - path = _REPO_ROOT / "backend" / "data" / "sql" / "277_payments_live_checkout_uidx.sql" + path = _REPO_ROOT / "backend" / "data" / "sql" / "279_payments_live_checkout_uidx.sql" source = path.read_text(encoding="utf-8") predicate = re.search(r"AND status IN \((.*?)\)", source, re.S) - assert predicate is not None, "предикат по статусам не найден в миграции 277" + assert predicate is not None, "предикат по статусам не найден в миграции 279" assert set(re.findall(r"'([^']+)'", predicate.group(1))) == set(_REUSABLE_STATUSES) @@ -704,7 +704,7 @@ def test_parallel_checkout_does_not_create_second_payment( ) -> None: """Двойной клик: соперник уже вставил строку, но ссылки от банка ещё нет. - Второй запрос обязан отбиться о частичный UNIQUE (миграция 277) и не пойти + Второй запрос обязан отбиться о частичный UNIQUE (миграция 279) и не пойти в банк — второй Init это второй холд на карте покупателя. Ответ 409, а не выдуманная ссылка.