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, а не выдуманная ссылка.