All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 19s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m21s
Два параллельных агента взяли ОДИН номер: 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.
65 lines
4.5 KiB
PL/PgSQL
65 lines
4.5 KiB
PL/PgSQL
-- 279_payments_live_checkout_uidx.sql
|
||
-- Идемпотентность checkout на уровне БД: не больше одного ЖИВОГО платежа на
|
||
-- пару (estimate_id, product_code).
|
||
--
|
||
-- ── WHY ──────────────────────────────────────────────────────────────────────
|
||
-- До этой миграции переиспользование живого платежа в
|
||
-- app/api/v1/payments.py::checkout держалось на «SELECT, потом INSERT» — ровно
|
||
-- на том, что шапка того же модуля называет дефектом. Обычный двойной клик по
|
||
-- кнопке оплаты даёт два параллельных запроса: оба проходят SELECT до того, как
|
||
-- любой из них вставит строку, оба делают INSERT, оба зовут Init — и на карте
|
||
-- покупателя появляются ДВА ХОЛДА. Порядок выполнения тут ничего не решает,
|
||
-- решает уникальный ключ: второй INSERT обязан отбиться самой БД.
|
||
--
|
||
-- ── Почему частичный, а не полный UNIQUE(estimate_id, product_code) ──────────
|
||
-- Полный запретил бы повторную покупку навсегда: после CANCELED/REJECTED
|
||
-- человек вправе попробовать оплатить заново, а после CONFIRMED — купить
|
||
-- второй раз. Поэтому в предикате только «живые» статусы — те, при которых
|
||
-- платёжная сессия ещё не завершилась. Список = _REUSABLE_STATUSES
|
||
-- (= _PRE_CONFIRM_STATUSES) в app/api/v1/payments.py; расхождение ловит
|
||
-- tests/test_payments_router.py::test_live_status_predicate_matches_code —
|
||
-- индекс, который шире кода, запрещает легитимный повтор, а который уже —
|
||
-- пропускает второй холд.
|
||
--
|
||
-- Предикат ссылается только на неизменяемые выражения (сравнение колонок с
|
||
-- литералами), поэтому индекс легален как частичный, а строка входит в него и
|
||
-- выходит автоматически при UPDATE статуса.
|
||
--
|
||
-- estimate_id IS NOT NULL — обязательная часть предиката: колонка nullable
|
||
-- (FK ON DELETE SET NULL, 233_payments.sql), а обычный UNIQUE считает NULL
|
||
-- отличным от NULL, так что без этого условия строки с NULL просто копились бы
|
||
-- в индексе без пользы.
|
||
--
|
||
-- ── Безопасность применения ─────────────────────────────────────────────────
|
||
-- Контур выключен (PAYMENTS_ENABLED=false), таблица на проде пуста
|
||
-- (проверено 2026-08-29: SELECT count(*) FROM payments → 0), поэтому обычный
|
||
-- CREATE UNIQUE INDEX не может упасть на существующих дублях и не блокирует
|
||
-- ничью запись. 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)
|
||
WHERE estimate_id IS NOT NULL
|
||
AND status IN (
|
||
'NEW',
|
||
'FORM_SHOWED',
|
||
'PREAUTHORIZING',
|
||
'AUTHORIZING',
|
||
'AUTHORIZED',
|
||
'3DS_CHECKING',
|
||
'3DS_CHECKED',
|
||
'CHECKING',
|
||
'CHECKED',
|
||
'PROCESSING',
|
||
'CONFIRMING'
|
||
);
|
||
|
||
COMMENT ON INDEX payments_live_estimate_product_uidx IS
|
||
'Не больше одного живого платежа на (estimate_id, product_code): двойной '
|
||
'клик по кнопке оплаты не создаёт второй холд на карте покупателя.';
|
||
|
||
COMMIT;
|