Merge remote-tracking branch 'forgejo/main' into fix/tradein-pool-browser-yandex-cian
All checks were successful
CI Trade-In / changes (pull_request) Successful in 19s
CI / changes (pull_request) Successful in 18s
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 2m11s

This commit is contained in:
bot-backend 2026-07-03 09:33:18 +03:00
commit 78bfa9555b
11 changed files with 300 additions and 67 deletions

View file

@ -216,6 +216,19 @@ def _confidence_label(c: float) -> str:
return "low" return "low"
_ROMAN_QUARTERS = ("I", "II", "III", "IV")
def _deals_quarter_label(d: _dt.date) -> str:
"""Дата сделок Росреестра → «I квартал 2026».
Росреестр отдаёт сделки ПОКВАРТАЛЬНО без точных дат: max(deal_date) = начало
квартала поставки (2026-01-01 = I квартал 2026), а НЕ «источник устарел».
Месяц 1-3I, 4-6II, 7-9III, 10-12IV (римские).
"""
return f"{_ROMAN_QUARTERS[(d.month - 1) // 3]} квартал {d.year}"
# Человеко-читаемые имена категорий для verbal breakdown (X1). # Человеко-читаемые имена категорий для verbal breakdown (X1).
_POI_CATEGORY_RU: dict[str, str] = { _POI_CATEGORY_RU: dict[str, str] = {
"school": "Школа", "school": "Школа",
@ -2873,12 +2886,16 @@ def analyze_parcel(
_deals_stale = ( _deals_stale = (
recent_n_val == 0 and max_period is not None and max_period < stale_cutoff recent_n_val == 0 and max_period is not None and max_period < stale_cutoff
) )
# Разные причины отсутствия сделок → честная микрокопия: устаревание # Честная микрокопия. Росреестр отдаёт сделки ПОКВАРТАЛЬНО (max(deal_date)
# источника vs просто нет сделок рядом (источник свеж). Обе показывают # = начало квартала поставки) — это НЕ «источник устарел». Для stale-ветки
# называем квартал поставки; иначе просто нет сделок рядом. Обе показывают
# предложение как основной контент. # предложение как основной контент.
_offer_only_label = ( _offer_only_label = (
"Сделки устарели — показываем динамику цен предложения" (
if _deals_stale f"Сделки Росреестра — за {_deals_quarter_label(max_period)} "
"(поставка поквартальная); показываем динамику цен предложения"
)
if _deals_stale and max_period is not None
else "Нет сделок рядом — показываем динамику цен предложения" else "Нет сделок рядом — показываем динамику цен предложения"
) )
market_trend = { market_trend = {
@ -2891,13 +2908,17 @@ def analyze_parcel(
"offer_trend": offer_block, "offer_trend": offer_block,
} }
elif recent_n_val == 0 and max_period is not None and max_period < stale_cutoff: elif recent_n_val == 0 and max_period is not None and max_period < stale_cutoff:
# Recent-окно пусто И latest deal старше 3 мес → источник устарел (не «нет рядом»). # Recent-окно пусто И latest deal старше 3 мес. Росреестр отдаёт сделки
# #2178: сюда попадаем ТОЛЬКО когда и offer-данных нет (offer_block is None) — # ПОКВАРТАЛЬНО (max(deal_date) = начало квартала поставки) — честно
# прежний source_stale остаётся честным «нечего показать». # называем квартал поставки, НЕ «источник устарел». #2178: сюда попадаем
# ТОЛЬКО когда и offer-данных нет (offer_block is None).
market_trend = { market_trend = {
"status": "source_stale", "status": "source_stale",
"as_of_date": max_period.isoformat(), "as_of_date": max_period.isoformat(),
"label": (f"Источник сделок устарел (данные до {max_period.strftime('%m.%Y')})"), "label": (
f"Сделки Росреестра — за {_deals_quarter_label(max_period)} "
"(поставка поквартальная); тренд по сделкам не показываем"
),
"recent_deals_count": 0, "recent_deals_count": 0,
} }
else: else:

View file

@ -94,7 +94,10 @@ _SVERDLOVSK_TOKEN = "свердлов"
# НЕ выход (это доп. нумерация станции), остаётся в имени. В живых данных фрагмент # НЕ выход (это доп. нумерация станции), остаётся в имени. В живых данных фрагмент
# стоит НЕ только в конце, но и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)») # стоит НЕ только в конце, но и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)»)
# → search по всему имени (не $-якорь), фрагмент вырезаем, остаток склеиваем. #2119 B1. # → search по всему имени (не $-якорь), фрагмент вырезаем, остаток склеиваем. #2119 B1.
_OUTPUT_SUFFIX_RE = re.compile(r"\((выход\s*№?\s*\d+)\)", re.IGNORECASE) # Группы: 1=«выход», 2=номер (для канонизации «выход №N» без разнобоя пробелов вокруг №,
# иначе «выход № 1» и «выход №1» породили бы РАЗНЫЕ output_name → дубли по
# ON CONFLICT (grs_name_norm, output_name)).
_OUTPUT_SUFFIX_RE = re.compile(r"\(\s*(выход)\s*№?\s*(\d+)\s*\)", re.IGNORECASE)
# FULL-строка: idx имени=2, проектная=3, загрузка=4, объём ТУ=5, свободная=6, # FULL-строка: idx имени=2, проектная=3, загрузка=4, объём ТУ=5, свободная=6,
# свободная %=7, уведомление(основание/срок/параметры)=8,9,10. # свободная %=7, уведомление(основание/срок/параметры)=8,9,10.
@ -168,12 +171,16 @@ def _split_grs_name(raw_name: str) -> tuple[str, str | None]:
«ГРС Арти (выход 2)» («ГРС Арти», «выход 2»); фрагмент «(выход N)» бывает «ГРС Арти (выход 2)» («ГРС Арти», «выход 2»); фрагмент «(выход N)» бывает
и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход 1) (5)» («ГРС-1 Свердловск (5)», и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход 1) (5)» («ГРС-1 Свердловск (5)»,
«выход 1»)) вырезаем его, остаток склеиваем и нормализуем двойные пробелы. «выход 1»)) вырезаем его, остаток склеиваем и нормализуем двойные пробелы.
output_name канонизируется в «выход N» (нижний регистр, ровно один пробел после
«выход», сразу «N» без пробела) разнобой пробелов вокруг «» иначе дал бы
разные output_name и дубли по ON CONFLICT (grs_name_norm, output_name).
«ГРС Арамиль (5)» без изменений (цифра в скобках НЕ выход, часть имени). «ГРС Арамиль (5)» без изменений (цифра в скобках НЕ выход, часть имени).
""" """
m = _OUTPUT_SUFFIX_RE.search(raw_name) m = _OUTPUT_SUFFIX_RE.search(raw_name)
if not m: if not m:
return raw_name, None return raw_name, None
output = _MULTISPACE_RE.sub(" ", m.group(1)).strip() # Канонический output_name: «выход №N» (число из группы 2, «№» приклеено к цифре).
output = f"{m.group(1).lower()}{m.group(2)}"
# Вырезаем фрагмент из ЛЮБОГО места имени и склеиваем остаток (нормализуя # Вырезаем фрагмент из ЛЮБОГО места имени и склеиваем остаток (нормализуя
# двойные пробелы, оставшиеся на месте выреза в середине). # двойные пробелы, оставшиеся на месте выреза в середине).
grs_name = raw_name[: m.start()] + raw_name[m.end() :] grs_name = raw_name[: m.start()] + raw_name[m.end() :]

View file

@ -204,6 +204,36 @@ def test_split_grs_name_no_output() -> None:
assert gg._split_grs_name("ГРС Кольцово") == ("ГРС Кольцово", None) assert gg._split_grs_name("ГРС Кольцово") == ("ГРС Кольцово", None)
def test_split_grs_name_output_before_station_digit_real_rows() -> None:
"""B1-хвост: РЕАЛЬНЫЕ прод-строки «(выход №N) (M)» → output_name извлекается.
Данные ГТЕ (asof 2026-06-01, снято 2026-07-02): FULL-строки, где «(выход N)»
стоит ПЕРЕД станционным «(M)». До search-фикса они оседали в grs_name с пустым
output_name (5 строк). Локим на точных прод-именах не регрессировать.
"""
cases = {
"ГРС-1 Свердловск (выход №1) (5)": ("ГРС-1 Свердловск (5)", "выход №1"),
"ГРС-1 Свердловск (выход №2) (5)": ("ГРС-1 Свердловск (5)", "выход №2"),
"ГРС-3 Екатеринбург (выход №1) (3)": ("ГРС-3 Екатеринбург (3)", "выход №1"),
"ГРС Реж (выход №1) (3)": ("ГРС Реж (3)", "выход №1"),
"ГРС Сухой Лог (выход №1) (4)": ("ГРС Сухой Лог (4)", "выход №1"),
}
for raw, expected in cases.items():
assert gg._split_grs_name(raw) == expected, raw
def test_split_grs_name_output_canonicalized() -> None:
"""output_name канонизируется в «выход №N» независимо от пробелов/регистра/«№».
«Выход 1» / «выход 1» / «выход 1» все дают «выход 1» (иначе разный
output_name дубли по ON CONFLICT (grs_name_norm, output_name)).
"""
for raw in ("ГРС Х (Выход № 1)", "ГРС Х (выход 1)", "ГРС Х (выход №1)"):
grs_name, output = gg._split_grs_name(raw)
assert output == "выход №1", raw
assert grs_name == "ГРС Х"
# ── _passes_region_filter ───────────────────────────────────────────────────── # ── _passes_region_filter ─────────────────────────────────────────────────────

View file

@ -38,34 +38,22 @@ function fmtPrice(v: number): string {
} }
/** /**
* P2 (#1871): дата as_of_date (YYYY-MM-DD) «месяц год» для caption о * Росреестр отдаёт сделки ПОКВАРТАЛЬНО без точных дат: max(deal_date)=2026-01-01
* устаревшем источнике. Возвращает null если дата отсутствует/некорректна. * означает «поставка за I квартал 2026», а НЕ «источник встал в январе». Дата
* YYYY-MM-DD «I квартал 2026» (римские I/II/III/IV по месяцу). null при
* отсутствии/некорректной дате. Парсим компоненты напрямую (не new Date() TZ).
*/ */
const RU_MONTHS = [ const ROMAN_QUARTERS = ["I", "II", "III", "IV"];
"январь",
"февраль",
"март",
"апрель",
"май",
"июнь",
"июль",
"август",
"сентябрь",
"октябрь",
"ноябрь",
"декабрь",
];
function formatAsOfMonth(asOf: string | undefined): string | null { function formatDealsQuarter(asOf: string | undefined | null): string | null {
if (!asOf) return null; if (!asOf) return null;
// Парсим YYYY-MM напрямую — НЕ через new Date() (UTC-полночь сдвинула бы
// месяц назад в TZ западнее UTC на границе, напр. 2026-01-01 → «декабрь 2025»).
const m = /^(\d{4})-(\d{2})/.exec(asOf); const m = /^(\d{4})-(\d{2})/.exec(asOf);
if (!m) return null; if (!m) return null;
const year = Number(m[1]); const year = Number(m[1]);
const monthIdx = Number(m[2]) - 1; const month = Number(m[2]);
if (monthIdx < 0 || monthIdx > 11) return null; if (month < 1 || month > 12) return null;
return `${RU_MONTHS[monthIdx]} ${year}`; const quarter = ROMAN_QUARTERS[Math.floor((month - 1) / 3)];
return `${quarter} квартал ${year}`;
} }
/** /**
@ -196,13 +184,15 @@ function OfferOnlyCard({
const sourceLine = const sourceLine =
sourceParts.join(" ") + (period != null ? ` · ${period}` : ""); sourceParts.join(" ") + (period != null ? ` · ${period}` : "");
// Caveat — почему показываем предложение вместо сделок. // Caveat — почему показываем предложение вместо сделок. Сделки Росреестра
const dealsMonth = formatAsOfMonth(dealsAsOfDate ?? undefined); // приходят ПОКВАРТАЛЬНО (max(deal_date) = начало квартала поставки) — это НЕ
// «источник устарел», а нормальный лаг квартальной выгрузки. Честная копия.
const dealsQuarter = formatDealsQuarter(dealsAsOfDate);
const caveat = dealsStale const caveat = dealsStale
? dealsMonth != null ? dealsQuarter != null
? `Сделки Росреестра устарели (до ${dealsMonth}) — показана динамика цен предложения.` ? `Сделки Росреестра — за ${dealsQuarter} (поставка поквартальная); показана динамика цен предложения (Объектив).`
: "Сделки Росреестра устарели — показана динамика цен предложения." : "Сделки Росреестра поставляются поквартально; показана динамика цен предложения (Объектив)."
: "Сделок Росреестра рядом нет — показана динамика цен предложения."; : "Сделок Росреестра рядом нет — показана динамика цен предложения (Объектив).";
return ( return (
<div <div
@ -303,8 +293,8 @@ export function MarketTrendBlock({ trend }: Props) {
trend.radius_km != null ? ` в радиусе ${trend.radius_km} км` : ""; trend.radius_km != null ? ` в радиусе ${trend.radius_km} км` : "";
const title = `Тренд рынка${radiusSuffix}`; const title = `Тренд рынка${radiusSuffix}`;
// #2178: сделки устарели/отсутствуют, но живёт динамика предложения — // #2178: сделок в recent-окне нет (поквартальная поставка / нет рядом), но живёт
// полноценная карточка тренда по предложению (Объектив), НЕ caveat-тупик. // динамика предложения — полноценная карточка тренда по предложению (Объектив).
if (status === "offer_only") { if (status === "offer_only") {
const offer = trend.offer_trend; const offer = trend.offer_trend;
// Defensive: без валидного offer-блока падаем в честный caveat, не крашимся. // Defensive: без валидного offer-блока падаем в честный caveat, не крашимся.
@ -321,19 +311,21 @@ export function MarketTrendBlock({ trend }: Props) {
return ( return (
<TrendCaveat <TrendCaveat
title={title} title={title}
message="Источник сделок устарел — тренд не показываем." message="Сделки Росреестра поставляются поквартально; тренд по сделкам не показываем."
/> />
); );
} }
// P2 (#1871): источник сделок устарел — caveat вместо фейкового тренда. // #1871/quarterly: сделки Росреестра приходят поквартально (as_of_date = начало
// квартала поставки), НЕ «устарели». Показываем честную квартальную подпись
// вместо тренда (recent-окно пусто, offer-данных тоже нет). Бэк-label в приоритете.
if (status === "source_stale") { if (status === "source_stale") {
const asOfMonth = formatAsOfMonth(trend.as_of_date); const quarter = formatDealsQuarter(trend.as_of_date);
const message = const message =
trend.label ?? trend.label ??
(asOfMonth != null (quarter != null
? `Источник сделок устарел (данные до ${asOfMonth}).` ? `Сделки Росреестра — за ${quarter} (поставка поквартальная); динамику цен предложения показать не удалось.`
: "Источник сделок устарел — тренд не показываем."); : "Сделки Росреестра поставляются поквартально; тренд по сделкам не показываем.");
return <TrendCaveat title={title} message={message} />; return <TrendCaveat title={title} message={message} />;
} }

View file

@ -0,0 +1,45 @@
/**
* Unit tests for §3 «Сети» number formatters (connection-capacity.ts).
*
* Focus: honest near-zero rendering of ГРС free capacity. При 1 знаке после
* запятой «0,044 тыс. м³/ч» округлялось бы в «0,0» ГРС с крохотной, но
* НЕнулевой свободной мощностью выглядела бы закрытой. Для 0 < |v| < 0,05
* показываем 2 знака; настоящий 0 остаётся «0,0».
*/
import { describe, expect, it } from "vitest";
import { formatThousandM3Hour } from "../connection-capacity";
// toLocaleString("ru-RU") даёт запятую-десятичную и Unicode-минус () для
// отрицательных — сверяем именно на этих формах.
describe("formatThousandM3Hour — честность околонуля", () => {
it("крохотная положительная мощность не схлопывается в 0,0", () => {
expect(formatThousandM3Hour(0.044)).toBe("0,04 тыс. м³/ч");
expect(formatThousandM3Hour("0.044")).toBe("0,04 тыс. м³/ч");
});
it("крохотная отрицательная мощность (дефицит) сохраняет знак и не нулится", () => {
// toLocaleString даёт минус (ASCII «-» или Unicode «−» в зависимости от ICU
// рантайма) + 2 знака — не «0,0». Сверяем знак + значимую часть, не глиф.
const out = formatThousandM3Hour(-0.044);
expect(out).not.toBeNull();
expect(out).toMatch(/^[-]0,04 тыс\. м³\/ч$/u);
});
it("настоящий ноль остаётся 0,0 (1 знак)", () => {
expect(formatThousandM3Hour(0)).toBe("0,0 тыс. м³/ч");
expect(formatThousandM3Hour("0")).toBe("0,0 тыс. м³/ч");
});
it("значения ≥ 0,05 — прежний 1 знак после запятой", () => {
expect(formatThousandM3Hour(1.205)).toBe("1,2 тыс. м³/ч");
expect(formatThousandM3Hour(0.05)).toBe("0,1 тыс. м³/ч");
expect(formatThousandM3Hour(42.2)).toBe("42,2 тыс. м³/ч");
});
it("null/undefined/нечисло → null (поле скрывается, не «0»)", () => {
expect(formatThousandM3Hour(null)).toBeNull();
expect(formatThousandM3Hour(undefined)).toBeNull();
expect(formatThousandM3Hour("н/д")).toBeNull();
});
});

View file

@ -132,14 +132,19 @@ export function formatMeters(
} }
// тыс. м³/ч (свободная/проектная мощность ГРС). Приводит строку→число (Decimal→str). // тыс. м³/ч (свободная/проектная мощность ГРС). Приводит строку→число (Decimal→str).
//
// Честность околонуля: при 1 знаке «0,044» округлился бы в «0,0» и ГРС с
// НЕнулевой (но крохотной) свободной мощностью выглядела бы закрытой. Для
// 0 < |v| < 0,05 показываем 2 знака («0,04» / «0,04»); настоящий 0 — «0,0».
export function formatThousandM3Hour( export function formatThousandM3Hour(
v: number | string | null | undefined, v: number | string | null | undefined,
): string | null { ): string | null {
const n = toFiniteNumber(v); const n = toFiniteNumber(v);
if (n === null) return null; if (n === null) return null;
const digits = n !== 0 && Math.abs(n) < 0.05 ? 2 : 1;
return `${n.toLocaleString("ru-RU", { return `${n.toLocaleString("ru-RU", {
minimumFractionDigits: 1, minimumFractionDigits: digits,
maximumFractionDigits: 1, maximumFractionDigits: digits,
})} тыс. м³/ч`; })} тыс. м³/ч`;
} }

View file

@ -11,6 +11,7 @@ import logging
import time import time
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
from uuid import uuid4
import httpx import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
@ -68,6 +69,22 @@ def _assert_allowed_url(url: str) -> None:
raise HTTPException(status_code=400, detail="host not allowed") raise HTTPException(status_code=400, detail="host not allowed")
def _safe_http_error(status_code: int, public_detail: str, log_context: str) -> HTTPException:
"""Defense-in-depth: не отдаём текст исключения в HTTP-ответ.
Текст psycopg/httpx-исключений может содержать DSN-фрагменты, внутренние
хосты, пути. Логируем полный exception с коротким error-id через
``logger.exception`` (уйдёт в GlitchTip), а в HTTP отдаём generic detail
с тем же error-id для корреляции.
ВАЖНО: вызывать только внутри ``except``-блока ``logger.exception``
полагается на активный exc_info.
"""
error_id = uuid4().hex[:8]
logger.exception("%s (error-id=%s)", log_context, error_id)
return HTTPException(status_code=status_code, detail=f"{public_detail} (error-id: {error_id})")
_ALL_SOURCES = ["avito", "cian", "yandex"] _ALL_SOURCES = ["avito", "cian", "yandex"]
@ -547,14 +564,16 @@ async def scrape_avito_house(
try: try:
enrichment = await fetch_house_catalog(house_url) enrichment = await fetch_house_catalog(house_url)
except Exception as e: except Exception as e:
logger.exception("avito-house: fetch failed for %s", house_url) raise _safe_http_error(
raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e 502, "fetch failed", f"avito-house: fetch failed for {house_url}"
) from e
try: try:
counters = save_house_catalog_enrichment(db, enrichment) counters = save_house_catalog_enrichment(db, enrichment)
except Exception as e: except Exception as e:
logger.exception("avito-house: save failed for %s", house_url) raise _safe_http_error(
raise HTTPException(status_code=500, detail=f"save failed: {e}") from e 500, "save failed", f"avito-house: save failed for {house_url}"
) from e
logger.info("avito-house ok: %s%s", house_url, counters) logger.info("avito-house ok: %s%s", house_url, counters)
return {"ok": True, "house_url": house_url, "counters": counters} return {"ok": True, "house_url": house_url, "counters": counters}
@ -576,14 +595,16 @@ async def scrape_avito_detail(
try: try:
enrichment = await fetch_detail(item_url) enrichment = await fetch_detail(item_url)
except Exception as e: except Exception as e:
logger.exception("avito-detail: fetch failed for %s", item_url) raise _safe_http_error(
raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e 502, "fetch failed", f"avito-detail: fetch failed for {item_url}"
) from e
try: try:
updated = save_detail_enrichment(db, enrichment) updated = save_detail_enrichment(db, enrichment)
except Exception as e: except Exception as e:
logger.exception("avito-detail: save failed for %s", item_url) raise _safe_http_error(
raise HTTPException(status_code=500, detail=f"save failed: {e}") from e 500, "save failed", f"avito-detail: save failed for {item_url}"
) from e
if not updated: if not updated:
# Listing с этим source_id ещё не существует в БД — был bypass через # Listing с этим source_id ещё не существует в БД — был bypass через
@ -625,8 +646,9 @@ async def scrape_avito_detail_backfill(
try: try:
result = await run_avito_detail_backfill(db, run_id=run_id, params=params) result = await run_avito_detail_backfill(db, run_id=run_id, params=params)
except Exception as e: except Exception as e:
logger.exception("avito-detail-backfill: run_id=%d crashed", run_id) raise _safe_http_error(
raise HTTPException(status_code=502, detail=f"backfill failed: {e}") from e 502, "backfill failed", f"avito-detail-backfill: run_id={run_id} crashed"
) from e
logger.info("avito-detail-backfill ok: run_id=%d %s", run_id, result.to_dict()) logger.info("avito-detail-backfill ok: run_id=%d %s", run_id, result.to_dict())
return {"ok": True, "run_id": run_id, "counters": result.to_dict()} return {"ok": True, "run_id": run_id, "counters": result.to_dict()}
@ -666,17 +688,22 @@ async def scrape_avito_imv(
has_loggia=has_loggia, has_loggia=has_loggia,
) )
except IMVAddressNotFoundError as e: except IMVAddressNotFoundError as e:
raise HTTPException(status_code=404, detail=f"IMV address not found: {e}") from e # Ожидаемое клиентское условие (адрес не в базе Avito), НЕ сбой — logger.warning
# без traceback, чтобы не шуметь exception-событиями в GlitchTip. Адрес — в лог,
# не в HTTP-ответ.
error_id = uuid4().hex[:8]
logger.warning("avito-imv: address not found for %s (error-id=%s)", address, error_id)
raise HTTPException(
status_code=404, detail=f"IMV address not found (error-id: {error_id})"
) from e
except Exception as e: except Exception as e:
logger.exception("avito-imv: fetch failed for %s", address) raise _safe_http_error(502, "fetch failed", f"avito-imv: fetch failed for {address}") from e
raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
try: try:
eval_id = save_imv_evaluation(db, result) eval_id = save_imv_evaluation(db, result)
history_saved = save_imv_placement_history(db, eval_id, result.placement_history) history_saved = save_imv_placement_history(db, eval_id, result.placement_history)
except Exception as e: except Exception as e:
logger.exception("avito-imv: save failed for %s", address) raise _safe_http_error(500, "save failed", f"avito-imv: save failed for {address}") from e
raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
logger.info( logger.info(
"avito-imv ok: addr=%s recommended=%d evaluation_id=%d history=%d", "avito-imv ok: addr=%s recommended=%d evaluation_id=%d history=%d",

View file

@ -518,11 +518,19 @@ async def upload_photo(
status_code=409, detail=f"photo limit reached ({_MAX_PHOTOS_PER_ESTIMATE})" status_code=409, detail=f"photo limit reached ({_MAX_PHOTOS_PER_ESTIMATE})"
) )
content = await file.read() # #2233: читаем тело чанками с жёстким капом, а не await file.read() целиком —
# иначе multi-GB аплоад буферизуется в RAM и OOM-killed backend (mem_limit 768m, #2214).
# 413 бросается СРАЗУ при превышении, остаток тела запроса НЕ читается.
chunks: list[bytes] = []
total = 0
while chunk := await file.read(64 * 1024):
total += len(chunk)
if total > _MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail="file too large (max 10 MB)")
chunks.append(chunk)
content = b"".join(chunks)
if not content: if not content:
raise HTTPException(status_code=400, detail="empty file") raise HTTPException(status_code=400, detail="empty file")
if len(content) > _MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail="file too large (max 10 MB)")
# Sanitize: re-encode through Pillow to drop EXIF, kill polyglot payloads, # Sanitize: re-encode through Pillow to drop EXIF, kill polyglot payloads,
# cap dimensions. Closes finding #6 from 2026-05-24 audit. # cap dimensions. Closes finding #6 from 2026-05-24 audit.

View file

@ -491,6 +491,69 @@ def test_upload_photo_owner_can_upload(trade_in_app: FastAPI, monkeypatch) -> No
assert resp.status_code == 200 assert resp.status_code == 200
class _InfiniteUploadFile:
"""UploadFile stub whose async read(size) yields endless 64KB chunks (#2233).
Simulates a multi-GB stream. Counts read() calls so the test can assert the
handler stops reading right after the cap is exceeded instead of draining the
whole body into RAM.
"""
content_type = "image/png"
filename = "huge.png"
def __init__(self) -> None:
self.read_calls = 0
async def read(self, size: int = -1) -> bytes:
self.read_calls += 1
return b"\x00" * (64 * 1024)
async def test_upload_photo_streams_over_cap_returns_413_without_full_read() -> None:
"""#2233: a stream larger than 10 MB → 413 raised early, body NOT fully read.
Acceptance: read() is called only ~(10MB/64KB)+1 times (161), proving the loop
bails on the first chunk that pushes total past _MAX_PHOTO_BYTES rather than
buffering an unbounded upload (which would OOM the 768m-capped container, #2214).
"""
from uuid import UUID
from fastapi import HTTPException
import app.core.auth as auth_mod
from app.api.v1.trade_in import _MAX_PHOTO_BYTES, upload_photo
auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment]
# guard SELECT → owner; count SELECT → 0. INSERT must never be reached.
db = MagicMock()
guard_result = MagicMock()
guard_result.fetchone.return_value = SimpleNamespace(created_by="kopylov")
count_result = MagicMock()
count_result.scalar_one.return_value = 0
db.execute.side_effect = [guard_result, count_result]
upload = _InfiniteUploadFile()
with pytest.raises(HTTPException) as exc_info:
await upload_photo(
estimate_id=UUID(_ESTIMATE_ID),
db=db,
file=upload, # type: ignore[arg-type]
x_authenticated_user="kopylov",
)
assert exc_info.value.status_code == 413
expected_reads = _MAX_PHOTO_BYTES // (64 * 1024) + 1 # 161
assert upload.read_calls == expected_reads
# sanity: bounded, nowhere near infinite
assert upload.read_calls < 200
# INSERT (3rd execute) never fired — nothing persisted
assert db.execute.call_count == 2
db.commit.assert_not_called()
# ── Derived analytics routes (representative: /houses, /imv-benchmark) ──────── # ── Derived analytics routes (representative: /houses, /imv-benchmark) ────────

View file

@ -14,7 +14,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
@ -564,3 +564,31 @@ def test_data_quality_pct_in_range(client: TestClient) -> None:
assert ( assert (
0.0 <= pct <= 100.0 0.0 <= pct <= 100.0
), f"source={src['source']} field={field_name} pct={pct} вне [0,100]" ), f"source={src['source']} field={field_name} pct={pct} вне [0,100]"
# ── Security: текст исключения не утекает в HTTP-ответ (#2234) ────────────────
def test_admin_error_detail_hides_exception_text(client: TestClient) -> None:
"""Внутренняя зависимость кидает исключение с 'секретным' текстом →
в теле ответа НЕТ секрета, есть generic detail + error-id; статус-код прежний.
Роут /scrape/avito-detail: fetch_detail падает с DSN-подобным сообщением.
"""
secret = "postgres://user:SECRETPASS@internal-host:5432/db"
with patch(
"app.api.v1.admin.fetch_detail",
new=AsyncMock(side_effect=Exception(secret)),
):
r = client.post(
"/api/v1/admin/scrape/avito-detail",
params={"item_url": "/ekaterinburg/kvartiry/1-k_kvartira_100"},
)
assert r.status_code == 502
body = r.json()
detail = body["detail"]
assert "SECRETPASS" not in detail
assert secret not in detail
assert "error-id:" in detail
assert detail.startswith("fetch failed")

View file

@ -15,6 +15,13 @@
handle_path /trade-in/api/* { handle_path /trade-in/api/* {
# handle_path вырезает префикс /trade-in перед прокси → # handle_path вырезает префикс /trade-in перед прокси →
# FastAPI получает /api/v1/... # FastAPI получает /api/v1/...
#
# #2233: первый рубеж против OOM на фото-аплоаде — режем тело >12MB на
# уровне Caddy (backend капит на 10MB чанково, см. upload_photo). Scoped
# ТОЛЬКО на /trade-in/api/* — не трогает фронт/остальной сайт.
request_body {
max_size 12MB
}
reverse_proxy tradein-backend:8000 reverse_proxy tradein-backend:8000
} }