Merge pull request 'fix(tradein/pii): удалить телефоны продавцов (NULL) + PII клиента (DROP) (#2217, #1969)' (#2303) from fix/tradein-pii-purge-phones-client into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m16s
Deploy Trade-In / build-frontend (push) Successful in 3m28s
Deploy Trade-In / build-backend (push) Successful in 1m51s
Deploy Trade-In / deploy (push) Successful in 1m34s

This commit is contained in:
lekss361 2026-07-03 20:21:46 +00:00
commit 0e2b4998c8
12 changed files with 76 additions and 57 deletions

View file

@ -40,8 +40,7 @@ class TradeInEstimateInput(BaseModel):
# CRM-поля (#395) — операционные, на расчёт оценки не влияют
ownership_type: str | None = Field(default=None, max_length=100)
has_mortgage: bool | None = None
client_name: str | None = Field(default=None, max_length=200)
client_phone: str | None = Field(default=None, max_length=50)
# client_name / client_phone удалены (PII purge #1969, DROP COLUMN 167).
class AnalogLot(BaseModel):

View file

@ -3302,7 +3302,7 @@ async def estimate_quality(
id, address, lat, lon,
area_m2, rooms, floor, total_floors,
year_built, house_type, repair_state, has_balcony,
ownership_type, has_mortgage, client_name, client_phone,
ownership_type, has_mortgage,
median_price, range_low, range_high, median_price_per_m2,
confidence, confidence_explanation, n_analogs,
analogs, actual_deals,
@ -3319,7 +3319,7 @@ async def estimate_quality(
:address, :lat, :lon,
:area, :rooms, :floor, :total_floors,
:year_built, :house_type, :repair_state, :has_balcony,
:ownership_type, :has_mortgage, :client_name, :client_phone,
:ownership_type, :has_mortgage,
:median_price, :range_low, :range_high, :median_ppm2,
:confidence, :explanation, :n_analogs,
CAST(:analogs_json AS jsonb),
@ -3352,8 +3352,6 @@ async def estimate_quality(
"has_balcony": payload.has_balcony,
"ownership_type": payload.ownership_type,
"has_mortgage": payload.has_mortgage,
"client_name": payload.client_name,
"client_phone": payload.client_phone,
"median_price": median_price,
"range_low": range_low,
"range_high": range_high,
@ -5344,7 +5342,7 @@ def _empty_estimate(
id, address,
area_m2, rooms, floor, total_floors,
year_built, house_type, repair_state, has_balcony,
ownership_type, has_mortgage, client_name, client_phone,
ownership_type, has_mortgage,
median_price, range_low, range_high, median_price_per_m2,
confidence, confidence_explanation, n_analogs,
analogs, actual_deals,
@ -5355,7 +5353,7 @@ def _empty_estimate(
CAST(:id AS uuid), :address,
:area, :rooms, :floor, :total_floors,
:year_built, :house_type, :repair_state, :has_balcony,
:ownership_type, :has_mortgage, :client_name, :client_phone,
:ownership_type, :has_mortgage,
0, 0, 0, 0,
'low', :explanation, 0,
'[]'::jsonb, '[]'::jsonb,
@ -5378,8 +5376,6 @@ def _empty_estimate(
"has_balcony": payload.has_balcony,
"ownership_type": payload.ownership_type,
"has_mortgage": payload.has_mortgage,
"client_name": payload.client_name,
"client_phone": payload.client_phone,
"explanation": reason,
"created_by": created_by,
"expires_at": expires_at,

View file

@ -83,7 +83,6 @@ LISTING_FIELD_PRIORITY: dict[str, list[str] | str] = {
"combined_wcs_count": ["cian_detail"],
"room_type": ["cian_detail", "avito_detail"],
"has_furniture": ["cian_serp", "avito_detail"],
"phones": ["cian_serp"],
"description": ["cian_serp", "avito_detail", "yandex_detail"],
"photo_urls": "union",
# Avito Domoteka unique

View file

@ -934,7 +934,11 @@ class CianScraper(BaseScraper):
repair_state: str | None = normalize_repair_state(offer.get("decoration"))
# ── Продавец ─────────────────────────────────────────────────────
phones: list[dict[str, Any]] = offer.get("phones") or []
# PII purge (#2217): телефоны продавцов больше не сохраняем.
# ScrapedLot.phones остаётся в модели (см. base.py), но на вход
# всегда передаём пустой список — write-path на listings.phones
# выключен здесь, а не на уровне upsert.
phones: list[dict[str, Any]] = []
is_homeowner: bool | None = offer.get("isByHomeowner")
is_pro_seller: bool | None = offer.get("isPro")

View file

@ -0,0 +1,26 @@
-- 166_purge_listings_phones.sql
--
-- PII PURGE (#2217): телефоны продавцов из listings.phones.
-- ПРОДУКТОВОЕ РЕШЕНИЕ (user): колонку НЕ дропаем — обнуляем данные и
-- останавливаем write-path у скрейперов (cian.py / scraper-kit serp.py
-- больше не читают offer.get("phones") — см. #2217 backend PR). Колонка
-- остаётся в схеме (и в модели ScrapedLot) как задел на будущее, просто
-- больше никогда не заполняется непустым значением.
--
-- ЧТО ДЕЛАЕТ:
-- UPDATE listings SET phones = NULL — вычищает все ранее сохранённые
-- телефоны продавцов (единственный источник, который их писал — Cian SERP).
--
-- ИДЕМПОТЕНТНОСТЬ:
-- WHERE phones IS NOT NULL -> повторный прогон трогает 0 строк.
--
-- НЕ ТРОГАЕТ: management_companies.phones (text[], миграция 021) — контакты
-- УК/ТСЖ, другая сущность, вне scope этого issue.
BEGIN;
UPDATE listings
SET phones = NULL
WHERE phones IS NOT NULL;
COMMIT;

View file

@ -0,0 +1,24 @@
-- 167_drop_client_pii.sql
--
-- PII PURGE (#1969): client_name / client_phone в trade_in_estimates.
-- ПРОДУКТОВОЕ РЕШЕНИЕ (user): DROP COLUMN (не NULL-обнуление, как для
-- listings.phones в 166 — здесь колонки не нужны совсем, CRM-поля
-- ownership_type/has_mortgage остаются). client_email физически не
-- существует (только имя в Sentry-scrub allowlist app/observability/
-- sentry_scrub.py) — трогать нечего кроме этих двух реальных колонок.
--
-- ЗАВИСИМОСТИ: нет VIEW / индексов на эти колонки (проверено разведкой
-- перед миграцией) — DROP безопасен.
--
-- Backend-код (app/schemas/trade_in.py, app/services/estimator.py — оба
-- INSERT INTO trade_in_estimates) деплоится синхронно с этой миграцией.
--
-- ИДЕМПОТЕНТНОСТЬ: DROP COLUMN IF EXISTS -> повторный прогон no-op.
BEGIN;
ALTER TABLE trade_in_estimates
DROP COLUMN IF EXISTS client_name,
DROP COLUMN IF EXISTS client_phone;
COMMIT;

View file

@ -170,3 +170,5 @@
163_disable_deactivate_stale_domklik.sql
164_yandex_url_canonicalize_active_dups.sql
165_remove_n1_source.sql
166_purge_listings_phones.sql
167_drop_client_pii.sql

View file

@ -92,7 +92,8 @@ def _cian_lot(**overrides) -> ScrapedLot:
"description_minhash": "abc123def456abc1",
"cadastral_number": "66:41:0204016:1234",
"building_cadastral_number": "66:41:0204016:100",
"phones": [{"countryCode": "7", "number": "9012345678"}],
# PII purge (#2217): phones больше не заполняется — по умолчанию пусто
# (ScrapedLot.phones default_factory=list, сериализуется в None).
"is_homeowner": True,
"is_pro_seller": False,
"bargain_allowed": True,
@ -163,10 +164,10 @@ def test_save_listings_persists_cian_specific_fields():
assert params["bargain_allowed"] is True
assert params["sale_type"] == "free"
# phones и metro_stations — JSON-строки, проверяем десериализацией
phones = json.loads(params["phones"])
assert phones == [{"countryCode": "7", "number": "9012345678"}]
# PII purge (#2217): phones больше не заполняется cian-фикстурой → None.
assert params["phones"] is None
# metro_stations — JSON-строка, проверяем десериализацией
metro = json.loads(params["metro_stations"])
assert metro[0]["name"] == "Геологическая"
assert metro[0]["time"] == 10

View file

@ -265,7 +265,8 @@ def test_cian_serp_description_minhash_list_int():
def test_cian_serp_phones():
"""phones — список dicts из state."""
"""PII purge (#2217): даже если raw offer содержит phones, lot.phones пуст —
скрейпер больше не читает offer.get("phones")."""
offer = _make_full_offer(has_phones=True)
html = _make_cian_serp_html([offer])
@ -274,9 +275,7 @@ def test_cian_serp_phones():
lot = lots[0]
assert isinstance(lot.phones, list)
assert len(lot.phones) == 1
assert lot.phones[0]["countryCode"] == "7"
assert lot.phones[0]["number"] == "9012345678"
assert lot.phones == []
def test_cian_serp_house_ext_id_newbuilding():

View file

@ -28,8 +28,6 @@ interface FormState {
has_balcony: boolean;
ownership_type: string;
has_mortgage: boolean;
client_name: string;
client_phone: string;
}
const INITIAL: FormState = {
@ -44,8 +42,6 @@ const INITIAL: FormState = {
has_balcony: false,
ownership_type: "",
has_mortgage: false,
client_name: "",
client_phone: "",
};
function validate(f: FormState): string | null {
@ -100,8 +96,6 @@ function toPayload(
if (rs) out.repair_state = rs;
if (f.ownership_type) out.ownership_type = f.ownership_type;
if (f.has_mortgage) out.has_mortgage = true;
if (f.client_name.trim()) out.client_name = f.client_name.trim();
if (f.client_phone.trim()) out.client_phone = f.client_phone.trim();
return out;
}
@ -389,34 +383,6 @@ export function EstimateForm({
<span style={{ color: "var(--fg)" }}>Ипотека / обременение</span>
</label>
</div>
<div className="field-row">
<div className="field">
<label className="field-label" htmlFor="cname">
Имя клиента <span className="hint">опц.</span>
</label>
<input
className="control"
id="cname"
type="text"
placeholder="Иван Петров"
value={form.client_name}
onChange={field("client_name")}
/>
</div>
<div className="field">
<label className="field-label" htmlFor="cphone">
Телефон <span className="hint">опц.</span>
</label>
<input
className="control"
id="cphone"
type="tel"
placeholder="+7 999 …"
value={form.client_phone}
onChange={field("client_phone")}
/>
</div>
</div>
</>
)}
</div>

View file

@ -62,8 +62,7 @@ export interface TradeInEstimateInput {
// CRM-поля (#395)
ownership_type?: string;
has_mortgage?: boolean;
client_name?: string;
client_phone?: string;
// client_name / client_phone удалены (PII purge #1969, DROP COLUMN 167).
// Координаты из автокомплита — позволяют бэкенду пропустить геокодинг
// для адресов, которые DaData/Yandex не резолвят по строке.
lat?: number | null;

View file

@ -878,7 +878,11 @@ class CianScraper(BaseScraper):
repair_state: str | None = normalize_repair_state(offer.get("decoration"))
# ── Продавец ─────────────────────────────────────────────────────
phones: list[dict[str, Any]] = offer.get("phones") or []
# PII purge (#2217): телефоны продавцов больше не сохраняем.
# ScrapedLot.phones остаётся в модели (см. base.py), но на вход
# всегда передаём пустой список — write-path на listings.phones
# выключен здесь, а не на уровне upsert.
phones: list[dict[str, Any]] = []
is_homeowner: bool | None = offer.get("isByHomeowner")
is_pro_seller: bool | None = offer.get("isPro")