From 2d4daceb2f3a8dc2508859cf8e0625bef3ecb413 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 18:44:18 +0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(mera):=20=D0=B0=D0=BD=D0=BE=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B0=D1=81=D1=87=D1=91?= =?UTF-8?q?=D1=82=20=D0=B8=20=D0=BA=D0=B0=D0=BF=D0=B0=D0=B1=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D1=82=D0=B8-=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B5=D0=B3=D0=BE=20=D0=B1=D0=B5=D1=81=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D1=82=D0=BD=D1=83=D1=8E=20=D1=87=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Публичный контур умел только подсказки и пробу покрытия: полный расчёт закрыт RBAC, а результат анонима нельзя было прочитать повторно — _assert_estimate_access отдаёт 404 на строку с created_by IS NULL всем, кроме админа, то есть расчёт жил ровно в теле POST-ответа и не переживал перезагрузку страницы. POST /api/public/mera/estimate делегирует в app.api.v1.trade_in.estimate (копии логики нет — иначе публичная когорта разъедется с платной) и отдаёт наружу только бесплатную часть: число аналогов и вердикт покрытия из той же coverage_probe. Цены, прогнозы и списки аналогов остаются в БД для платного контура. Согласие 152-ФЗ обязательно и строго True на уровне схемы, поэтому отказ происходит до входа в хендлер — раньше, чем адрес физлица дошёл бы до БД. POST /api/public/mera/estimate/read читает бесплатную часть по токену (secrets.token_urlsafe(32), в БД только sha256, срок жизни 7 дней, миграция 278). Токен едет телом: access-лог Caddy пишет URI целиком, и капабилити-ссылка в пути легла бы в файл рядом с IP посетителя — тот же довод, по которому POST'ом сделан /suggest. Постоянный путь заодно не требует префиксной ветки в rbac._PUBLIC_PATHS. Всё закрыто флагом public_estimate_enabled (дефолт false → 404): включение открывает запись ПДн и требует решения владельца вместе с правкой политики. --- tradein-mvp/backend/app/api/public/mera.py | 291 ++++++++++++++++- tradein-mvp/backend/app/core/config.py | 8 + tradein-mvp/backend/app/core/rbac.py | 7 + .../278_trade_in_estimates_public_token.sql | 37 +++ .../backend/tests/test_public_mera_api.py | 11 +- .../tests/test_public_mera_estimate.py | 304 ++++++++++++++++++ 6 files changed, 640 insertions(+), 18 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/278_trade_in_estimates_public_token.sql create mode 100644 tradein-mvp/backend/tests/test_public_mera_estimate.py diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py index fbd28d9f..9e0af142 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -1,4 +1,4 @@ -"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный, ровно три ручки. +"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный. ЗАЧЕМ ОТДЕЛЬНЫЙ ПРЕФИКС, А НЕ ОТКРЫТИЕ КУСКА /api/v1/* ------------------------------------------------------- @@ -28,18 +28,27 @@ API, нужно было выбрать одно из двух: `rbac_guard` (app/core/rbac.py) требует `X-Authenticated-User` для любого non-public пути. Все ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками — не префиксом: множество там — frozenset с проверкой `path in ...`, и -добавление префиксной ветки ради двух путей расширило бы механизм, которым -пользуется весь бэкенд, ради одной фичи. +добавление префиксной ветки расширило бы механизм, которым пользуется весь +бэкенд, ради одной фичи. Поэтому и чтение по токену — POST с постоянным +путём `/estimate/read`, а не `GET /estimate/{token}`: переменный сегмент +пути потребовал бы ровно такой префиксной ветки (плюс сам токен уехал бы в +access-лог Caddy, см. `PublicEstimateTokenInput`). -ЧТО ЭТИ РУЧКИ НЕ ДЕЛАЮТ ------------------------ -Ни одна из них не пишет в БД строк с адресом пользователя: `/coverage` — -чистое чтение (один SELECT), `/suggest` — прокси автокомплита. Это не -случайность, а условие, при котором публичная форма может работать ДО того, -как появится контур согласия 152-ФЗ (issue #2895: сегодня адрес физлица -попадает в `trade_in_estimates` раньше любого согласия, а пути удаления -данных в бэкенде нет). Платный расчёт, который писать будет, открывается -отдельно и только вместе с этим контуром. +ЧТО ЭТИ РУЧКИ ДЕЛАЮТ С ДАННЫМИ +------------------------------ +`/suggest` и `/coverage` не пишут в БД ничего: первый — прокси автокомплита, +второй — один SELECT. Это по-прежнему так и меняться не должно. + +`/estimate` — единственная, которая ПИШЕТ строку с адресом физлица (issue +#2895), и поэтому устроена иначе: она закрыта флагом +`settings.public_estimate_enabled` (дефолт false → 404) и требует явного +согласия 152-ФЗ в payload'е строго `True` — на уровне схемы, то есть 422 +прилетает до входа в хендлер и до любого обращения к БД. Пути удаления +данных в бэкенде всё ещё нет; включать флаг на проде — вместе с ним и с +правкой п.5.5 политики обработки ПДн. + +`/estimate/read` возвращает по капабилити-токену ТОЛЬКО бесплатную часть +(`PublicEstimateResult`) — цену и прогнозы продаёт платный контур. БЮДЖЕТЫ ------- @@ -58,21 +67,28 @@ non-public пути. Все ручки перечислены в `_PUBLIC_PATHS` from __future__ import annotations import asyncio +import hashlib import logging +import secrets from datetime import datetime -from typing import Annotated +from typing import Annotated, Literal -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel, Field from sqlalchemy import text from sqlalchemy.orm import Session from app.api.v1.geocode import SuggestResponse, suggest_addresses -from app.api.v1.trade_in import coverage_probe +from app.api.v1.trade_in import coverage_probe, estimate +from app.core.config import settings from app.core.db import get_db from app.core.public_request import install_address_log_redaction, public_request_scope from app.core.ratelimit import SlidingWindowLimiter, _client_ip -from app.schemas.trade_in import CoverageProbeInput, CoverageProbeResponse +from app.schemas.trade_in import ( + CoverageProbeInput, + CoverageProbeResponse, + TradeInEstimateInput, +) logger = logging.getLogger(__name__) @@ -97,11 +113,22 @@ _COVERAGE_LIMIT = 15 # поэтому бюджет шире соседних: он здесь против перебора-в-цикле, а не против # денежных трат. Лэндинг дёргает ручку один раз на загрузку страницы. _SHOWCASE_LIMIT = 60 +# Расчёт — самый дорогой шаг публичного контура (геокодер + десяток SQL по +# листингам + запись строки). Бюджет намеренно ниже пробы покрытия: живой +# человек нажимает «рассчитать» единицы раз, а анонимная месячная квота +# (settings.anon_estimate_quota_limit) обходится сменой IP — минутное окно +# делает такой обход дорогим по времени. +_ESTIMATE_LIMIT = 5 +# Чтение по токену дешевле расчёта (один SELECT по уникальному индексу), но +# ослаблять его до бесконечности нельзя: перебор токенов — это перебор. +_ESTIMATE_READ_LIMIT = 30 _WINDOW_S = 60.0 _suggest_limiter = SlidingWindowLimiter(limit=_SUGGEST_LIMIT, window_s=_WINDOW_S) _coverage_limiter = SlidingWindowLimiter(limit=_COVERAGE_LIMIT, window_s=_WINDOW_S) _showcase_limiter = SlidingWindowLimiter(limit=_SHOWCASE_LIMIT, window_s=_WINDOW_S) +_estimate_limiter = SlidingWindowLimiter(limit=_ESTIMATE_LIMIT, window_s=_WINDOW_S) +_estimate_read_limiter = SlidingWindowLimiter(limit=_ESTIMATE_READ_LIMIT, window_s=_WINDOW_S) # ── Общий суточный потолок публичных подсказок ────────────────────────────── # @@ -359,6 +386,8 @@ def public_stats( ) for row in rows } + + class ShowcaseDeal(BaseModel): """Одна строка витрины «МЕРА сказала X — продали за Y». @@ -490,3 +519,233 @@ def public_showcase( for r in rows ], ) + + +# ── Анонимный расчёт и повторное чтение его бесплатной части ──────────────── +# +# ФЛАГ. Обе ручки ниже мертвы, пока `settings.public_estimate_enabled` не +# включён в .env.runtime: это первая пара публичных ручек, которая ПИШЕТ в +# `trade_in_estimates` адрес физлица, а такое включение — продуктовое решение +# владельца (нужна правка п.5.5 политики обработки ПДн, она сегодня анонимный +# расчёт не описывает), а не следствие мержа кода. Тот же приём, что у +# `payments_enabled`. Отвечаем 404, а не 403: выключенная ручка не должна +# подтверждать, что она существует. +# +# ОБЩИЙ СУТОЧНЫЙ ПОТОЛОК. Per-IP окно ограничивает одного клиента, но не сумму: +# 5/мин с адреса — это 7200 расчётов в сутки, каждый из которых дёргает +# геокодер и пишет строку. Потолок ниже — про защиту закрытого контура и +# квоты геокодера (тот же довод, что у `_DAILY_SUGGEST_BUDGET`), исчерпание — +# сигнал абуза, не штатный режим. +_DAILY_ESTIMATE_BUDGET = 300 +_daily_estimate_limiter = SlidingWindowLimiter(limit=_DAILY_ESTIMATE_BUDGET, window_s=86_400.0) +_ESTIMATE_GLOBAL_KEY = "public-estimate" + +# Срок жизни капабилити-ссылки. Ссылка — единственный способ анонима вернуться +# к своему расчёту (аккаунта у него нет), поэтому недели мало не будет для +# «оплатил, закрыл вкладку, вернулся вечером», а бесконечной она быть не может: +# это ссылка на данные о конкретной квартире конкретного человека. +_TOKEN_TTL = "7 days" + + +def _require_public_estimate_enabled() -> None: + """404, пока публичный расчёт не включён владельцем явно.""" + if not settings.public_estimate_enabled: + raise HTTPException(status_code=404, detail="Not Found") + + +def _token_hash(token: str) -> str: + """sha256(hex) — в БД лежит только это, сам токен не хранится нигде. + + Без соли намеренно: вход — `secrets.token_urlsafe(32)` (256 бит), перебор + по словарю невозможен, а соль сделала бы невозможным поиск по равенству. + """ + return hashlib.sha256(token.encode()).hexdigest() + + +class PublicEstimateInput(TradeInEstimateInput): + """Вход анонимного расчёта: тот же payload, что у закрытого контура, но + согласие 152-ФЗ — ОБЯЗАТЕЛЬНОЕ и строго True. + + В базовой схеме `consent: bool | None = None`: сделать его обязательным там + нельзя — B2B-пилоты согласия в UI не дают, у них договор, и их фронт поля + не шлёт. Здесь же анонимный посетитель — единственный источник согласия, + поэтому `Literal[True]`: без него Pydantic отвечает 422 ДО входа в + хендлер, то есть до первого обращения к БД. Это не дубль гейта в + `estimate_quality` (тот ловит любых вызывающих), а его сдвиг на самую + раннюю возможную границу — «согласие фиксируется до первого INSERT» + перестаёт зависеть от порядка строк внутри эстиматора. + """ + + consent: Literal[True] + + +class PublicEstimateTokenInput(BaseModel): + """Токен едет ТЕЛОМ, а ручка чтения — POST, а не GET /{token}. + + Причина ровно та же, по которой POST'ом сделан `/suggest`: access-лог Caddy + на публичном домене пишет URI целиком, поэтому капабилити-ссылка в пути + легла бы в файл рядом с IP посетителя — и любой, у кого есть доступ к + логам (или их бэкапу), открыл бы чужой расчёт. Токен — это пароль; пароли + в URL не кладут. + """ + + token: str = Field(min_length=16, max_length=128) + + +class PublicEstimateResult(BaseModel): + """БЕСПЛАТНАЯ часть расчёта — ровно то, что можно показать до оплаты. + + Здесь СОЗНАТЕЛЬНО нет ни одного поля из `AggregatedEstimate` с ценой, + прогнозом или списком аналогов (`median_price_rub`, `range_*`, + `expected_sold_*`, `analogs`, `actual_deals`, `market_percentile`, + `est_days_on_market`, `cian_valuation`, `avito_imv`, `dkp_corridor`…). + Модель отдельная, а не `AggregatedEstimate` с `exclude`: список исключений + надо помнить и дополнять при каждом новом поле эстиматора, а отдельная + модель молчит по умолчанию — новое платное поле не утечёт само. + + `coverage` — тот же ответ, что у бесплатной пробы `/coverage` (в нём цен + нет по построению, см. `CoverageProbeResponse`): число похожих квартир, + возраст объявлений, вердикт покрытия. null — координаты дома не + разрезолвились, честнее отдать «неизвестно», чем правдоподобное число. + """ + + token: str + token_expires_at: datetime + n_analogs: int + coverage: CoverageProbeResponse | None = None + + +def _coverage_for( + db: Session, lat: float | None, lon: float | None, rooms: int, area_m2: float +) -> CoverageProbeResponse | None: + """Вердикт покрытия для уже посчитанной оценки — через ту же `coverage_probe`. + + Своей копии порогов здесь нет намеренно: разъедься она с бесплатной пробой, + один и тот же адрес получил бы «есть данные» на одном экране и «мало» на + соседнем. + """ + if lat is None or lon is None: + return None + return coverage_probe( + payload=CoverageProbeInput(lat=lat, lon=lon, rooms=rooms, area_m2=area_m2), + db=db, + ) + + +@router.post("/estimate", response_model=PublicEstimateResult) +async def public_estimate( + request: Request, + response: Response, + payload: PublicEstimateInput, + db: Annotated[Session, Depends(get_db)], +) -> PublicEstimateResult: + """Анонимный расчёт: считает полную оценку, отдаёт только бесплатную часть. + + Делегирует в `app.api.v1.trade_in.estimate` — ту же функцию, что обслуживает + закрытый контур, а не копию её тела. Оттуда же бесплатно достаётся всё + анти-абузное хозяйство: анонимная квота на связку (подписанная cookie + IP) + с лимитом `settings.anon_estimate_quota_limit`, семафор одновременности, + 503 вместо непрозрачного 502 при сбое и consent-гейт (`require_consent` + включается ровно потому, что мы зовём её без `X-Authenticated-User`). + + Полный расчёт при этом СОХРАНЯЕТСЯ в `trade_in_estimates` целиком — платный + контур (сосед, `/api/v1/trade-in/r/{token}`) открывает его после оплаты по + своему токену. Наружу здесь уезжает только `PublicEstimateResult`. + """ + _require_public_estimate_enabled() + _enforce(_estimate_limiter, request, "estimate") + + daily_retry = _daily_estimate_limiter.retry_after(_ESTIMATE_GLOBAL_KEY) + if daily_retry is not None: + logger.error( + "публичные расчёты исчерпали суточный бюджет (%d) — закрытый контур " + "защищён, но форма на лэндинге сейчас не считает", + _DAILY_ESTIMATE_BUDGET, + ) + raise HTTPException( + status_code=429, + detail="Расчёт временно недоступен. Попробуйте позже.", + headers={"Retry-After": str(int(daily_retry) + 1)}, + ) + _daily_estimate_limiter.record(_ESTIMATE_GLOBAL_KEY) + + # Пометка публичного запроса — как у `/suggest`: внутри цепочки геокодер + # печатает введённый адрес, а публичная форма обещает обратное. + with public_request_scope(): + result = await estimate( + payload=payload, + request=request, + response=response, + db=db, + x_authenticated_user=None, + ) + + # Токен выдаём ПОСЛЕ успешного расчёта: ссылка на несуществующий результат + # не нужна никому, а строка в БД уже есть — estimate() её закоммитил. + token = secrets.token_urlsafe(32) + row = db.execute( + text( + """ + UPDATE trade_in_estimates + SET public_token_hash = :token_hash, + public_token_expires_at = NOW() + CAST(:ttl AS interval) + WHERE id = CAST(:id AS uuid) + RETURNING public_token_expires_at + """ + ), + {"token_hash": _token_hash(token), "ttl": _TOKEN_TTL, "id": str(result.estimate_id)}, + ).fetchone() + db.commit() + if row is None: # pragma: no cover — оценка только что записана этой же транзакцией + raise HTTPException(status_code=503, detail="estimate temporarily unavailable") + + return PublicEstimateResult( + token=token, + token_expires_at=row.public_token_expires_at, + n_analogs=result.n_analogs, + coverage=_coverage_for( + db, result.target_lat, result.target_lon, payload.rooms, payload.area_m2 + ), + ) + + +@router.post("/estimate/read", response_model=PublicEstimateResult) +def public_estimate_read( + request: Request, + payload: PublicEstimateTokenInput, + db: Annotated[Session, Depends(get_db)], +) -> PublicEstimateResult: + """Повторное чтение бесплатной части по капабилити-токену. + + Существует потому, что у анонима нет аккаунта: без этой ручки результат + жил бы ровно в теле POST-ответа и не переживал бы перезагрузку страницы + (`_assert_estimate_access` в закрытом контуре отдаёт 404 на оценку с + `created_by IS NULL` всем, кроме админа — и это правильно, менять его + ради анонима значило бы ослабить IDOR-гейт для всех). + + Просрочка и «нет такого токена» отвечают ОДИНАКОВО (404): различать их + значит подтверждать существование расчёта тому, кто угадал токен. + """ + _require_public_estimate_enabled() + _enforce(_estimate_read_limiter, request, "estimate-read") + + row = db.execute( + text( + """ + SELECT n_analogs, lat, lon, rooms, area_m2, public_token_expires_at + FROM trade_in_estimates + WHERE public_token_hash = :token_hash + AND public_token_expires_at > NOW() + """ + ), + {"token_hash": _token_hash(payload.token)}, + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Расчёт не найден или ссылка устарела.") + + return PublicEstimateResult( + token=payload.token, + token_expires_at=row.public_token_expires_at, + n_analogs=row.n_analogs, + coverage=_coverage_for(db, row.lat, row.lon, row.rooms, row.area_m2), + ) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index af4081b8..959ad820 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -1226,5 +1226,13 @@ class Settings(BaseSettings): # обязаны отказывать сразу, ничего не вызывая у T-Bank. payments_enabled: bool = Field(default=False, validation_alias="PAYMENTS_ENABLED") + # Kill-switch анонимного расчёта на публичном домене (POST/GET + # /api/public/mera/estimate*). false — обе ручки отвечают 404, как будто их + # нет: включение публичного расчёта открывает запись адреса физлица в + # trade_in_estimates, и решение это продуктовое (нужна правка п.5.5 политики + # обработки ПДн), а не «смержили код». Тот же приём и та же причина, что у + # payments_enabled выше. ENV: PUBLIC_ESTIMATE_ENABLED. + public_estimate_enabled: bool = Field(default=False, validation_alias="PUBLIC_ESTIMATE_ENABLED") + settings = Settings() diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index 524b53da..723ace63 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -122,6 +122,13 @@ _PUBLIC_PATHS = frozenset( # СВОЮ таблицу-витрину, где по построению нет ни адреса, ни владельца — # район + характеристики квартиры + пара «прогноз/факт». "/api/public/mera/showcase", + # Анонимный расчёт и повторное чтение его бесплатной части. Оба — + # POST с точным путём: у чтения токен едет ТЕЛОМ, а не в URI, иначе + # капабилити-ссылка легла бы в access-лог Caddy рядом с IP посетителя + # (тот же довод, что у /suggest — см. app/api/public/mera.py). + # Обе ручки дополнительно закрыты флагом settings.public_estimate_enabled. + "/api/public/mera/estimate", + "/api/public/mera/estimate/read", } ) # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед diff --git a/tradein-mvp/backend/data/sql/278_trade_in_estimates_public_token.sql b/tradein-mvp/backend/data/sql/278_trade_in_estimates_public_token.sql new file mode 100644 index 00000000..0b4445a6 --- /dev/null +++ b/tradein-mvp/backend/data/sql/278_trade_in_estimates_public_token.sql @@ -0,0 +1,37 @@ +-- 278_trade_in_estimates_public_token.sql +-- Purpose: capability-ссылка на БЕСПЛАТНУЮ часть анонимного расчёта +-- (GET /api/public/mera/estimate/{token}). +-- +-- ЗАЧЕМ КОЛОНКА, А НЕ ОТДЕЛЬНАЯ ТАБЛИЦА. Токен — атрибут ровно одной оценки и +-- живёт/умирает вместе с ней; таблица 1:1 добавила бы join и вторую точку, +-- где строку можно забыть удалить. +-- +-- ХРАНИМ ХЭШ, А НЕ ТОКЕН. Дамп/бэкап/случайный SELECT в поддержке не должны +-- давать доступ к чужому расчёту. sha256 без соли осознанно: вход — +-- secrets.token_urlsafe(32), 256 бит энтропии, словарь по нему невозможен, +-- а соль сломала бы поиск по равенству (пришлось бы сканировать таблицу). +-- +-- ПОЧЕМУ ОТДЕЛЬНЫЙ СРОК, А НЕ expires_at/retain_until. expires_at — про то, +-- сколько живёт сам расчёт, retain_until двигает контур оплаты. Ссылка на +-- бесплатную часть — третье, независимое обещание («ссылка работает N дней»), +-- и склеивать его с чужими сроками значит менять его молча при каждой правке +-- соседей. +-- +-- Dependencies: trade_in_estimates (миграция 001+). +-- Deploy order: применять после 277. + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS public_token_hash text, + ADD COLUMN IF NOT EXISTS public_token_expires_at timestamptz; + +-- UNIQUE, а не просто индекс: коллизия хэшей означала бы, что по одной ссылке +-- отдаются два разных расчёта. Partial — токен есть у меньшинства строк +-- (B2B-пилоты его не получают вовсе), индексировать NULL'ы незачем. +CREATE UNIQUE INDEX IF NOT EXISTS idx_trade_in_estimates_public_token_hash + ON trade_in_estimates (public_token_hash) + WHERE public_token_hash IS NOT NULL; + +COMMENT ON COLUMN trade_in_estimates.public_token_hash IS + 'sha256(hex) капабилити-токена бесплатной части (app/api/public/mera.py). Сам токен не хранится.'; +COMMENT ON COLUMN trade_in_estimates.public_token_expires_at IS + 'До какого момента работает GET /api/public/mera/estimate/{token}. Не связан с expires_at/retain_until.'; diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py index 8124329c..b0033ac7 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -110,9 +110,16 @@ def client() -> TestClient: # ── 1-2. Периметр и его связка с rbac ──────────────────────────────────────── -def test_public_router_exposes_exactly_four_routes() -> None: +def test_public_router_exposes_exactly_the_declared_routes() -> None: paths = {r.path for r in public_mera.router.routes} - assert paths == {"/suggest", "/coverage", "/stats", "/showcase"}, ( + assert paths == { + "/suggest", + "/coverage", + "/stats", + "/showcase", + "/estimate", + "/estimate/read", + }, ( "изменился набор публичных (анонимных) ручек МЕРЫ. Это не рефакторинг: " "всё под /api/public/ проксируется на meraocenka.ru целиком и доступно " "без идентичности. Обнови тест ОСОЗНАННО вместе с rbac._PUBLIC_PATHS." diff --git a/tradein-mvp/backend/tests/test_public_mera_estimate.py b/tradein-mvp/backend/tests/test_public_mera_estimate.py new file mode 100644 index 00000000..26df13b2 --- /dev/null +++ b/tradein-mvp/backend/tests/test_public_mera_estimate.py @@ -0,0 +1,304 @@ +"""Анонимный расчёт /api/public/mera/estimate* — что здесь запинено и почему. + +1. СОГЛАСИЕ ДО ЗАПИСИ. Отказ без согласия обязан случиться ДО того, как + адрес физлица дойдёт до БД. Поэтому проверяется не только код 422, но и + то, что расчёт вообще не запускался и в сессию не ушло ни одного запроса: + «422, но строка записана» выглядит в логах как успех приватности и им не + является. + +2. ФЛАГ. Выключенный `public_estimate_enabled` обязан давать 404 — иначе + мерж этого кода сам по себе открывает наружу запись ПДн. + +3. ТОКЕН. В БД уходит ХЭШ, а не токен; выборка отфильтрована по сроку + жизни; «нет такого» и «протух» неотличимы снаружи. + +4. БЕСПЛАТНАЯ ЧАСТЬ. Публичный ответ не содержит ни одного платного поля. + Проверяется набором ключей на равенство: любое добавленное поле роняет + тест, в том числе случайно добавленное платное. +""" + +from __future__ import annotations + +import hashlib +import os +import sys +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) +sys.modules.setdefault("weasyprint.CSS", _wp_mock) +sys.modules.setdefault("weasyprint.HTML", _wp_mock) + +import pytest # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from app.api.public import mera as public_mera # noqa: E402 +from app.core.db import get_db # noqa: E402 +from app.schemas.trade_in import AggregatedEstimate, CoverageProbeResponse # noqa: E402 + +PREFIX = "/api/public/mera" + +_BODY = { + "address": "Малышева 51", + "area_m2": 54.0, + "rooms": 2, + "city_hint": "Екатеринбург", + "consent": True, +} + +_FAKE_COVERAGE = CoverageProbeResponse( + status="ok", + n_listings=34, + median_listing_age_days=44, + n_with_age=6, + radius_m=1000, + city="Екатеринбург", + threshold=10, +) + +_TOKEN_EXPIRES = datetime(2026, 9, 5, 12, 0, tzinfo=UTC) + + +def _fake_estimate_result() -> MagicMock: + """Результат закрытого контура. MagicMock, а не собранный AggregatedEstimate: + хендлеру нужны четыре атрибута, а перечисление всех полей платной модели + здесь означало бы, что тест придётся править при каждой правке эстиматора.""" + result = MagicMock() + result.estimate_id = "11111111-1111-1111-1111-111111111111" + result.n_analogs = 27 + result.target_lat = 56.838 + result.target_lon = 60.597 + return result + + +@pytest.fixture(autouse=True) +def _reset_limiters(): + public_mera._estimate_limiter._hits.clear() + public_mera._estimate_read_limiter._hits.clear() + public_mera._daily_estimate_limiter._hits.clear() + yield + public_mera._estimate_limiter._hits.clear() + public_mera._estimate_read_limiter._hits.clear() + public_mera._daily_estimate_limiter._hits.clear() + + +@pytest.fixture() +def db() -> MagicMock: + session = MagicMock() + session.execute.return_value.fetchone.return_value = MagicMock( + public_token_expires_at=_TOKEN_EXPIRES, + n_analogs=27, + lat=56.838, + lon=60.597, + rooms=2, + area_m2=54.0, + ) + return session + + +@pytest.fixture() +def client(db: MagicMock) -> TestClient: + """Без rbac-мидлвари: узость auth-исключения проверяет соседний + tests/test_public_mera_api.py, здесь предмет — сами ручки.""" + app = FastAPI() + app.include_router(public_mera.router, prefix=PREFIX) + app.dependency_overrides[get_db] = lambda: db + return TestClient(app) + + +@pytest.fixture() +def flag_on(): + with patch.object(public_mera.settings, "public_estimate_enabled", True): + yield + + +# ── 1. Согласие фиксируется до первого обращения к БД ──────────────────────── + + +def test_estimate_without_consent_is_422_and_writes_nothing( + client: TestClient, db: MagicMock, flag_on: None +) -> None: + body = {k: v for k, v in _BODY.items() if k != "consent"} + with patch.object(public_mera, "estimate", AsyncMock()) as estimate_mock: + resp = client.post(f"{PREFIX}/estimate", json=body) + + assert resp.status_code == 422, resp.text + assert not estimate_mock.called, ( + "расчёт запустился без согласия — адрес физлица дошёл бы до " + "trade_in_estimates раньше, чем человек что-либо разрешил" + ) + assert db.execute.call_args_list == [], ( + "в БД ушёл запрос при отсутствии согласия: проверка согласия сдвинулась " + "ПОСЛЕ работы с данными" + ) + + +def test_estimate_with_consent_false_is_also_422(client: TestClient, flag_on: None) -> None: + """`consent: false` — это осознанный отказ, а не «поле не прислали».""" + with patch.object(public_mera, "estimate", AsyncMock()) as estimate_mock: + resp = client.post(f"{PREFIX}/estimate", json={**_BODY, "consent": False}) + assert resp.status_code == 422, resp.text + assert not estimate_mock.called + + +# ── 2. Флаг ────────────────────────────────────────────────────────────────── + + +def test_estimate_is_404_while_flag_is_off(client: TestClient) -> None: + with patch.object(public_mera, "estimate", AsyncMock()) as estimate_mock: + resp = client.post(f"{PREFIX}/estimate", json=_BODY) + assert resp.status_code == 404, resp.text + assert not estimate_mock.called + + +def test_estimate_read_is_404_while_flag_is_off(client: TestClient, db: MagicMock) -> None: + resp = client.post(f"{PREFIX}/estimate/read", json={"token": "x" * 32}) + assert resp.status_code == 404, resp.text + assert db.execute.call_args_list == [] + + +def test_flag_default_is_off() -> None: + """Дефолт — выключено: мерж кода не должен открывать запись ПДн наружу.""" + from app.core.config import Settings + + assert Settings.model_fields["public_estimate_enabled"].default is False + + +# ── 3. Токен ───────────────────────────────────────────────────────────────── + + +def test_estimate_stores_token_hash_not_token( + client: TestClient, db: MagicMock, flag_on: None +) -> None: + with ( + patch.object(public_mera, "estimate", AsyncMock(return_value=_fake_estimate_result())), + patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE), + ): + resp = client.post(f"{PREFIX}/estimate", json=_BODY) + + assert resp.status_code == 200, resp.text + token = resp.json()["token"] + assert len(token) >= 32, "короткий токен перебираем" + + params = db.execute.call_args_list[0].args[1] + assert token not in str(params), "сам токен уехал в БД — дамп базы открывает чужие расчёты" + assert params["token_hash"] == hashlib.sha256(token.encode()).hexdigest() + + +def test_read_filters_by_expiry_and_hash(client: TestClient, db: MagicMock, flag_on: None) -> None: + token = "t" * 40 + with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): + resp = client.post(f"{PREFIX}/estimate/read", json={"token": token}) + assert resp.status_code == 200, resp.text + + sql = str(db.execute.call_args_list[0].args[0]) + assert "public_token_expires_at > NOW()" in sql, ( + "из выборки пропал срок жизни ссылки — протухший токен снова читает расчёт" + ) + params = db.execute.call_args_list[0].args[1] + assert params["token_hash"] == hashlib.sha256(token.encode()).hexdigest() + + +def test_unknown_or_expired_token_is_404(client: TestClient, db: MagicMock, flag_on: None) -> None: + """Чужой и протухший токен неотличимы: 404 в обоих случаях. + + Мок сессии отдаёт None ровно так же, как реальный SELECT с предикатом + `public_token_expires_at > NOW()` — то есть и для несуществующего токена, + и для просроченного. + """ + db.execute.return_value.fetchone.return_value = None + resp = client.post(f"{PREFIX}/estimate/read", json={"token": "z" * 40}) + assert resp.status_code == 404, resp.text + assert "не найден" in resp.json()["detail"].lower() + + +# ── 4. В публичном ответе нет платного ─────────────────────────────────────── + +# Поля AggregatedEstimate, которые продукт продаёт. Список не для красоты: он +# сверяется с реальной моделью ниже, поэтому переименование поля в эстиматоре +# не оставит здесь мёртвую строку-обманку. +_PAID_FIELDS = { + "median_price_rub", + "range_low_rub", + "range_high_rub", + "median_price_per_m2", + "market_percentile", + "analogs", + "actual_deals", + "expected_sold_price_rub", + "est_days_on_market", + "price_trend", +} + +_FREE_FIELDS = {"token", "token_expires_at", "n_analogs", "coverage"} + + +def test_paid_field_names_still_exist_in_estimator_model() -> None: + """Контроль самого контроля: если поле переименовали, тест ниже сравнивал бы + публичный ответ с несуществующими именами и был бы зелёным по построению.""" + missing = _PAID_FIELDS - set(AggregatedEstimate.model_fields) + assert not missing, f"эти поля исчезли из AggregatedEstimate, обнови список: {missing}" + + +def test_public_result_model_exposes_only_free_fields() -> None: + assert set(public_mera.PublicEstimateResult.model_fields) == _FREE_FIELDS, ( + "изменился состав публичного ответа. Любое поле отсюда видит любой аноним " + "без оплаты — правь ОСОЗНАННО вместе с этим тестом" + ) + + +def test_public_estimate_response_leaks_no_paid_field(client: TestClient, flag_on: None) -> None: + with ( + patch.object(public_mera, "estimate", AsyncMock(return_value=_fake_estimate_result())), + patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE), + ): + resp = client.post(f"{PREFIX}/estimate", json=_BODY) + body = resp.json() + assert set(body) == _FREE_FIELDS, f"публичный ответ отдаёт лишнее: {set(body) - _FREE_FIELDS}" + assert not _PAID_FIELDS & set(body) + # Вложенный coverage тоже без цен — по построению CoverageProbeResponse, + # но проверяем, а не верим: он мог обрасти полем «медиана ₽/м²». + assert not _PAID_FIELDS & set(body["coverage"]) + assert body["n_analogs"] == 27 + assert body["coverage"]["median_listing_age_days"] == 44 + + +def test_read_returns_same_free_shape(client: TestClient, flag_on: None) -> None: + """POST и повторное чтение обязаны отдавать ОДНУ форму: разойдись они — + фронт после перезагрузки страницы показал бы не то же самое.""" + with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): + resp = client.post(f"{PREFIX}/estimate/read", json={"token": "t" * 40}) + assert resp.status_code == 200, resp.text + assert set(resp.json()) == _FREE_FIELDS + assert resp.json()["n_analogs"] == 27 + + +# ── 5. Бюджеты ─────────────────────────────────────────────────────────────── + + +def test_estimate_rate_limited_per_ip(client: TestClient, flag_on: None) -> None: + with ( + patch.object(public_mera, "estimate", AsyncMock(return_value=_fake_estimate_result())), + patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE), + ): + codes = [ + client.post(f"{PREFIX}/estimate", json=_BODY).status_code + for _ in range(public_mera._ESTIMATE_LIMIT + 1) + ] + assert codes[:-1] == [200] * public_mera._ESTIMATE_LIMIT + assert codes[-1] == 429, f"бюджет не сработал: {codes}" + + +def test_daily_budget_stops_estimates_for_everyone(client: TestClient, flag_on: None) -> None: + """Общий потолок — про сумму по всем IP, а не про одного клиента.""" + for _ in range(public_mera._DAILY_ESTIMATE_BUDGET): + public_mera._daily_estimate_limiter.record(public_mera._ESTIMATE_GLOBAL_KEY) + with patch.object(public_mera, "estimate", AsyncMock()) as estimate_mock: + resp = client.post(f"{PREFIX}/estimate", json=_BODY) + assert resp.status_code == 429, resp.text + assert int(resp.headers["Retry-After"]) > 0 + assert not estimate_mock.called From dcfea2ad3961a3039dedaa0012e72a6009603c93 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 19:06:25 +0500 Subject: [PATCH 2/3] =?UTF-8?q?test(mera/b2c):=20=D0=BF=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=20kwargs=20=D0=B4=D0=B5=D0=BB=D0=B5=D0=B3=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B0=D0=BD=D0=BE=D0=BD=D0=B8=D0=BC=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82=D0=B0,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D1=84=D0=B0=D0=BA=D1=82=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Весь анти-абузный контур публичной ручки (анонимная квота cookie+IP, семафор, 503 вместо 502, consent-гейт) держится на одном аргументе: в app.api.v1.trade_in.estimate уходит x_authenticated_user=None. Проверял это ноль тестов: estimate везде замокан AsyncMock, который принимает любую сигнатуру, — подмена None на чтение заголовка запроса оставляла все 14 тестов зелёными, а публичная форма начинала считать от чужого имени мимо квоты. Новый тест шлёт запрос С заголовком X-Authenticated-User: admin и сверяет фактические await_args.kwargs; заодно требует, чтобы аргументы ехали по имени (позиционный вызов обесценивает сверку) и чтобы аргумент вообще присутствовал (дефолт эстиматора — чужая гарантия, не наша). Проверено падением: подмена на request.headers.get даёт «пришло: 'admin'». Там же UPDATE токена: параметры сверялись только по хэшу, id строки — нет. Теперь пинится result.estimate_id: токен обязан вешаться на только что посчитанную оценку. Проверено подменой параметра — красный по значению. test_read_filters_by_expiry_and_hash оставлен текстовым: живого Postgres с миграцией 278 здесь нет, а поведенческий тест, ни разу не прогнанный, — это ещё один зелёный по построению. Вместо этого в самом тесте написано, что он проверяет (предикат есть в тексте SQL, в параметрах хэш) и чего НЕ проверяет (сессия — MagicMock, запрос не исполняется, протухший токен не отсекается), и чем его заменить, когда БД появится. --- .../tests/test_public_mera_estimate.py | 84 ++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/tradein-mvp/backend/tests/test_public_mera_estimate.py b/tradein-mvp/backend/tests/test_public_mera_estimate.py index 26df13b2..5d844f49 100644 --- a/tradein-mvp/backend/tests/test_public_mera_estimate.py +++ b/tradein-mvp/backend/tests/test_public_mera_estimate.py @@ -15,6 +15,15 @@ 4. БЕСПЛАТНАЯ ЧАСТЬ. Публичный ответ не содержит ни одного платного поля. Проверяется набором ключей на равенство: любое добавленное поле роняет тест, в том числе случайно добавленное платное. + +5. ДЕЛЕГАЦИЯ. Весь анти-абузный рассказ ручки (анонимная квота на связку + cookie+IP, семафор одновременности, 503 вместо 502, consent-гейт) держится + ровно на ОДНОМ факте: в `app.api.v1.trade_in.estimate` уходит + `x_authenticated_user=None`. `AsyncMock` съедает любую сигнатуру, поэтому + «расчёт вызвали» тут ничего не значит — проверяются фактические kwargs + вызова, причём запрос идёт С заголовком `X-Authenticated-User`: подмена + `None` на чтение заголовка обязана красить тест, иначе публичная ручка + молча начнёт считать чужим пользователем и мимо анонимной квоты. """ from __future__ import annotations @@ -61,6 +70,7 @@ _FAKE_COVERAGE = CoverageProbeResponse( ) _TOKEN_EXPIRES = datetime(2026, 9, 5, 12, 0, tzinfo=UTC) +_FAKE_ESTIMATE_ID = "11111111-1111-1111-1111-111111111111" def _fake_estimate_result() -> MagicMock: @@ -68,7 +78,7 @@ def _fake_estimate_result() -> MagicMock: хендлеру нужны четыре атрибута, а перечисление всех полей платной модели здесь означало бы, что тест придётся править при каждой правке эстиматора.""" result = MagicMock() - result.estimate_id = "11111111-1111-1111-1111-111111111111" + result.estimate_id = _FAKE_ESTIMATE_ID result.n_analogs = 27 result.target_lat = 56.838 result.target_lon = 60.597 @@ -187,9 +197,29 @@ def test_estimate_stores_token_hash_not_token( params = db.execute.call_args_list[0].args[1] assert token not in str(params), "сам токен уехал в БД — дамп базы открывает чужие расчёты" assert params["token_hash"] == hashlib.sha256(token.encode()).hexdigest() + assert params["id"] == _FAKE_ESTIMATE_ID, ( + "токен вешается не на ту строку: UPDATE обязан идти по id ТОЛЬКО ЧТО " + "посчитанной оценки (result.estimate_id), иначе ссылка либо ведёт в чужой " + "расчёт, либо не ведёт никуда" + ) def test_read_filters_by_expiry_and_hash(client: TestClient, db: MagicMock, flag_on: None) -> None: + """ЧТО проверено: в тексте отправленного SQL присутствует предикат срока жизни, + а в параметры уехал ХЭШ токена, не сам токен. + + ЧЕГО НЕ проверено: что протухший токен действительно не читается. Сессия здесь — + MagicMock, запрос не исполняется: SQL не разбирается, `fetchone()` отдаёт строку + из фикстуры независимо от WHERE. Поэтому зелёными останутся, например, предикат, + перенесённый туда, где он ничего не отсекает, сравнение NOW() с полем не того + типа и любая ошибка в самом сравнении — текст-то совпадает. + + Поведенческой версии нет намеренно: она требует живого Postgres с миграцией 278 + (образец self-skip-теста — tests/test_purge_expired_trade_in_data.py::_live_session), + а писать её вслепую, ни разу не прогнав, значит завести ещё один тест, зелёный + по построению. Появится доступный Postgres — этот тест заменяется на вставку + двух строк (свежий токен и просроченный) с проверкой 200 против 404. + """ token = "t" * 40 with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): resp = client.post(f"{PREFIX}/estimate/read", json={"token": token}) @@ -277,7 +307,57 @@ def test_read_returns_same_free_shape(client: TestClient, flag_on: None) -> None assert resp.json()["n_analogs"] == 27 -# ── 5. Бюджеты ─────────────────────────────────────────────────────────────── +# ── 5. Делегация в закрытый контур ─────────────────────────────────────────── + + +def test_estimate_delegates_anonymously_despite_auth_header( + client: TestClient, db: MagicMock, flag_on: None +) -> None: + """Единственный тест, который смотрит НА АРГУМЕНТЫ делегации, а не на факт вызова. + + Запрос идёт с `X-Authenticated-User: admin` — то есть ровно тем заголовком, + который в закрытом контуре означает «это авторизованный пользователь». Ручка + обязана всё равно позвать эстиматор анонимом: `x_authenticated_user=None` + включает там consent-гейт и анонимную квоту на связку cookie+IP. Пробрось + сюда заголовок — и публичная форма начнёт считать от чужого имени в обход + квоты, а все остальные тесты этого файла останутся зелёными: `AsyncMock` + принимает любую сигнатуру. + """ + with ( + patch.object( + public_mera, "estimate", AsyncMock(return_value=_fake_estimate_result()) + ) as estimate_mock, + patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE), + ): + resp = client.post( + f"{PREFIX}/estimate", json=_BODY, headers={"X-Authenticated-User": "admin"} + ) + + assert resp.status_code == 200, resp.text + call = estimate_mock.await_args + assert call is not None, "делегации не было вовсе" + assert call.args == (), ( + "аргументы поехали позиционно — сверка по именам ниже перестала что-либо " + "проверять; зови эстиматор с ключевыми словами" + ) + assert "x_authenticated_user" in call.kwargs, ( + "аргумент исчез из вызова: у эстиматора он по умолчанию None, но тогда " + "гарантия анонимности держится на чужом дефолте, а не на этой ручке" + ) + assert call.kwargs["x_authenticated_user"] is None, ( + "публичная ручка передала пользователя в закрытый контур: consent-гейт и " + "анонимная квота выключаются, аноним считает от чужого имени " + f"(пришло: {call.kwargs['x_authenticated_user']!r})" + ) + # Остальное едет тем же вызовом: сессия — та, что отдана зависимостью (иначе + # UPDATE токена ниже пишет в другую транзакцию), тело — то, что прислал аноним. + assert call.kwargs["db"] is db + assert call.kwargs["payload"].address == _BODY["address"] + assert call.kwargs["payload"].area_m2 == _BODY["area_m2"] + assert call.kwargs["payload"].rooms == _BODY["rooms"] + + +# ── 6. Бюджеты ─────────────────────────────────────────────────────────────── def test_estimate_rate_limited_per_ip(client: TestClient, flag_on: None) -> None: From 6255eccc7cd9ae0899908e2467e8ef14bfa3f004 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 19:14:34 +0500 Subject: [PATCH 3/3] =?UTF-8?q?fix(tradein):=20=D0=BC=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B1=D0=B5=D0=B7=20lock=5Ftimeout=20=E2=80=94=20=D0=B3?= =?UTF-8?q?=D0=B5=D0=B9=D1=82=20#2752?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 278 делает ALTER TABLE trade_in_estimates ADD COLUMN + CREATE UNIQUE INDEX на ЖИВОЙ таблице (1123 строки на проде). ALTER берёт ACCESS EXCLUSIVE: без lock_timeout он встал бы в очередь за запросами приложения и утащил их за собой. Обёрнуто в BEGIN + SET LOCAL lock_timeout = '5s' + COMMIT по образцу 272_houses_region_code.sql. --- .../data/sql/278_trade_in_estimates_public_token.sql | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tradein-mvp/backend/data/sql/278_trade_in_estimates_public_token.sql b/tradein-mvp/backend/data/sql/278_trade_in_estimates_public_token.sql index 0b4445a6..f2fc98c4 100644 --- a/tradein-mvp/backend/data/sql/278_trade_in_estimates_public_token.sql +++ b/tradein-mvp/backend/data/sql/278_trade_in_estimates_public_token.sql @@ -19,6 +19,12 @@ -- -- Dependencies: trade_in_estimates (миграция 001+). -- Deploy order: применять после 277. +BEGIN; +-- Конвенция проекта (#2752): ALTER TABLE на ЖИВОЙ таблице берёт ACCESS +-- EXCLUSIVE, и без lock_timeout встанет в очередь за запросами приложения, +-- утащив их за собой. trade_in_estimates — таблица боевая (1123 строки на +-- 29.08), поэтому это не формальность. +SET LOCAL lock_timeout = '5s'; ALTER TABLE trade_in_estimates ADD COLUMN IF NOT EXISTS public_token_hash text, @@ -35,3 +41,5 @@ COMMENT ON COLUMN trade_in_estimates.public_token_hash IS 'sha256(hex) капабилити-токена бесплатной части (app/api/public/mera.py). Сам токен не хранится.'; COMMENT ON COLUMN trade_in_estimates.public_token_expires_at IS 'До какого момента работает GET /api/public/mera/estimate/{token}. Не связан с expires_at/retain_until.'; + +COMMIT;