fix(mera/b2c): восемь находок финального аудита прода — включая три моих собственных #3247
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#3247
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/audit-all"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Полный проход по живому сайту шестью независимыми линзами (числа, работоспособность, дизайн глазами, мобильный, периметр, данные под витриной). Здесь — правки по найденному. Пять веток сведены в одну, чтобы не гнать пять деплоев подряд.
Что в аудите оказалось в порядке (тоже результат): периметр держится — шесть попыток обхода белого списка отбиты, выключенные ручки не выдают схему,
noindexна всех восьми страницах, внешних ресурсов не подгружается. Тракт расчёта честен от подсказки адреса до результата, включая «дата известна у 5 из 25» и исчезающую плитку при нехватке данных.Три находки — мои собственные ошибки
1. Подпись замера называла не то окно. На витрине стояло «сделки с июня 2025 года, 327 сделок». Выборка бэктеста берётся
ORDER BY id DESC LIMIT :sample, и все 327 попавших сделок оказались одного квартала. Витрина это подтверждала: 20 из 20 строк — «II квартал 2026».Побочно вскрылось важное:
deals.deal_dateу Росреестра — метка квартала, а не дата сделки (в окне всего 4 различных значения). Значит квартал и есть предельная точность, которую данные позволяют назвать.Чиню подпись, а не замер: случайной выборки в скрипте нет, и честнее назвать окно, которое покрыто. Стало «сделки II квартала 2026 года».
2. Число опубликовано без даты замера. «14,5% / 88% / 325 из 327» — разовый ручной прогон, дата лежала в поле
sourceи на страницу не выводилась. Он старел бы молча. Дата теперь печатается прямо над плитками, а протухание сделано чьей-то обязанностью: тест краснеет, когда замеру больше 100 дней, с инструкцией «перегнать либо снять блок; двигать дату без пересчёта — враньё». Фальсифицирован.Заодно показана доля выборки: 327 из 5 954 годных сделок квартала — 5,5%, а не «столько и было».
3. Колонка «Цена ДКП» показывала реконструкцию.
fact_rub = price_per_m2 × area_m2, при том чтоdeals.price_rubлежит в той же строке. Корень:price_per_m2в базе целочисленный, поэтому произведение промахивается на единицы рублей — 4 799 995 против 4 800 000 в договоре. Подпись обещала документ, значение было производной.Теперь берётся
price_rub;err_pctсчитается от того же числа, что показано, — разъехаться не может по построению. Строка без цены не показывается, реконструкцию не подставляем (это вернуло бы дефект). Покрытие замерено: 33 555 из 33 555.Две находки аудита опровергнуты замером
«Экспозиция занижена втрое» — неверно. Аудитор сравнил
listing_date(26 дн.) сpublish_date(75 дн.) и предположил разный смысл полей. Проверка: там, где заполнены оба, они совпадают — у Яндекса 10 761 из 10 903, медиана разницы 0 дней. Разница в 75 против 26 — артефакт разных выборок, а не семантики.Но рядом нашёлся настоящий дефект: Домклик выпадал из метрики целиком (0 из 3 061 активной строки — у него нет
listing_date), метрика считалась по 83,6% объявлений, и подпись об этом молчала. ИсправленоCOALESCE→ 95,2%, медиана не изменилась. Охват теперь назван в подписи.«Половина городов из списка ведёт в отказ» — довод негоден. Аудитор считал покрытие по
listings.city, а там хранится город свипа скрейпера, не геокод объявления: вокруг Берёзовского все 90 строк лежат сcity='Екатеринбург'. Симуляция настоящей когорты/coverageна случайных адресах: пустая когорта — 3% в ЕКБ и 21% в Среднеуральске, то есть редкий край, а не половина. Реальная разница в другом — доля проверок, дотягивающих до городского порога: ЕКБ 83%, Ревда 16%. Это и показано.Остальные находки
FAQ обещал процесс, которого нет, и спорил с самой страницей. Ответ утверждал, что мы «отслеживаем снятие объявлений с публикации» и по этим данным считаем расхождение. Расхождение считается только по ценам ДКП; признак снятия не пишется намеренно — в коде прямо сказано, что он невыводим из наших данных (
is_activeозначает «мы видели», покрытие обхода 10–35%). На продеdelisted— 0 строк. Ответ переписан на то, что делается, плюс отдельный абзац о том, почему снятие не считается сделкой.«150 ₽ против двух месяцев вашей жизни» и «не зависли на полгода» — обе длительности взяты из воздуха при собственной плитке страницы в 26 дней. Убраны, а не заменены измеренными: единственный близкий замер цензурирован (считается по тем, кто ещё висит) и сроком продажи не является.
Результат формы уходил под сгиб. На телефоне после отправки на экране оставалась та же форма — визуально «ничего не произошло». Добавлены перевод фокуса на область ответа и прокрутка с уважением к
prefers-reduced-motion. Форма не убирается: её замена сломала бы редактирование параметров.Публичная ручка падала в 500 мимо рейт-лимита.
{"lon": 1e400}→ 500, двенадцать запросов подряд без единого 429. Механизм:json.loadsпринимаетInfinity, pydantic отбивает по границе, значение попадает в полеinputответа 422 — и сам ответ об ошибке не сериализуется сallow_nan=False, падая уже после входа в ответ.Починено на уровне сборки ответа, а не поля: один обработчик на приложение закрывает любое число любой будущей схемы. Рейт-лимит перенесён в зависимости у всех шести публичных ручек — там же, где сегодня уже чинили гейт флага по той же причине (FastAPI валидирует тело раньше тела хендлера). Не закрыто осознанно и записано в докстринге: тело, вообще не разбираемое как JSON, даёт 422 до зависимостей и остаётся под общим лимитером.
Проверено
Бэкенд 5113 passed / 35 skipped,
ruffчисто. Фронтендtsc·eslint·vitest 161/161· изоляция публичного дерева. Каждая починка фальсифицирована руками: сломал → красный по значению → вернул.После мержа нужен ручной пересчёт витрины — иначе на сайте останутся старые реконструированные цены:
Аудит живого сайта 30.08.2026: POST /api/public/mera/coverage с {"lat":56.8,"lon":1e400,...} отвечал 500, и двенадцать таких запросов подряд дали двенадцать пятисоток и ни одного 429. Две независимые поломки в одном месте, обе воспроизведены локально до правки. 1. 500 вместо 422. json.loads принимает Infinity/-Infinity/NaN, а 1e400 даёт inf переполнением. Pydantic отбивает такое поле по границам и кладёт значение в input ошибки, а ответ об ошибке сериализуется json.dumps(allow_nan=False) и падает уже после входа в ответ. Ломается не поле, а сборка ответа об ошибке — одна на всё приложение, поэтому и обработчик один (app/core/http_errors.py), а не валидатор на lon. 2. Лимитер мимо. _enforce стоял первой строкой тела хендлера, а FastAPI валидирует тело позже зависимостей, но раньше тела — до проверки просто не доходило. Та же поправка места, что уже сделана сегодня у _require_public_estimate_enabled: перенос в dependencies. Сделано для всех ручек файла, не только coverage. У /estimate и /estimate/read флаг остаётся первой зависимостью — 429 на выключенной ручке подтверждал бы её существование. Тесты двусторонние: снятие обработчика роняет 4 проверки 422, возврат лимитера в тело роняет проверку бюджета (проверено).