gendesign/tradein-mvp/backend/data/sql/231_trade_in_privacy_retention.sql
bot-backend 881730bf20
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 10s
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 3m6s
fix(tradein/privacy): не удалять B2B-строки в purge + находить телефон в другом формате при erasure (#2547)
Deep-review HIGH: purge_expired_trade_in_data удалял trade_in_estimates по
expires_at без разбора B2B/B2C -- эта колонка TTL ссылки/PDF, а не срок
хранения строки, и её единообразно проставляет каждой оценке estimator.py.
Прод-аудит: 1040/1057 строк просрочены, 911 из них у пилотов (admin,
kopylov, brusnika, praktika, pilottest, admintest, user1). DELETE теперь
ограничен created_by IS NULL -- ровно анонимная B2C-популяция (129 строк).
Докстринг миграции 231 переписан: явные цифры аудита, необратимость,
чек-лист (свежий SELECT count + один supervised прогон) перед enable.

Deep-review MEDIUM: erase_person_data сравнивал phone точным =, а lead.py
сохраняет номер как прислали (без нормализации, намеренно) -- разное
форматирование одного и того же номера не находилось, 0 строк удалялось,
но ответ всё равно был 200 "данные удалены". Сравнение переведено на
regexp_replace(x, '\D', '', 'g') с обеих сторон.

Оба фикса проверены живьём (throwaway Postgres 16 в docker, вне обычного
mock-only CI-лейна): без гварда пилотская строка удалялась вместе с
анонимной; без нормализации разноформатный телефон не находился. С
фиксами -- находит/не находит ровно как задумано. Добавлены self-skipping
live-DB тесты (паттерн test_house_dedup_merge.py::_live_session) плюс
статические SQL-guard тесты.
2026-08-06 19:49:13 +03:00

119 lines
8.7 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 231_trade_in_privacy_retention.sql
-- ЭТАП 4 B2C launch — retention enforcement (152-ФЗ): срок хранения ДОЛЖЕН
-- приводить к физическому удалению, а не быть декоративным полем.
--
-- WHY:
-- trade_in_estimates.expires_at существовал (migration 004), но применялся
-- ТОЛЬКО как read-time фильтр (GET /estimate/{id}: "AND expires_at > NOW()")
-- — ни одна фоновая задача не удаляла строки после истечения TTL. Данные
-- физлица (адрес) жили в БД бессрочно вопреки декларированному сроку.
-- trade_in_leads было ещё хуже: там вообще НЕ было понятия TTL/expires_at —
-- телефон + согласие хранились бессрочно с момента создания таблицы
-- (172_trade_in_leads.sql).
--
-- WHAT:
-- 1. trade_in_leads.expires_at (timestamptz NOT NULL) — backfill для
-- существующих строк = created_at + 180 дней (тот же default, что
-- settings.trade_in_lead_retention_days в app/core/config.py на момент
-- этой миграции; 180 дней = рабочий MVP-default для НЕконвертированных
-- маркетинговых лидов, см. обоснование в config.py — конкретный
-- юридически обоснованный срок хранения это решение DPO/юриста, не
-- инженера). Новые строки получают expires_at на insert-time
-- (app/api/v1/lead.py, тем же паттерном, что trade_in_estimates).
-- 2. Индекс на trade_in_leads.expires_at — для batched-DELETE ниже.
-- 3. scrape_schedules seed: purge_expired_trade_in_data — ночная задача
-- (app/tasks/purge_expired_trade_in_data.py, kit-handler в
-- app/services/product_handlers.py), физически удаляющая ИСТЁКШИЕ
-- строки в trade_in_estimates И trade_in_leads пачками (batch_size из
-- default_params, лимит max_batches за один прогон — см. таск-докстринг).
-- ON DELETE CASCADE (007_estimate_photos, 018_avito_imv_evaluations) и
-- ON DELETE SET NULL (172_trade_in_leads.estimate_id) уже подчищают
-- зависимые таблицы автоматически — этот файл их не трогает.
--
-- Seeded с enabled=false (тот же осторожный паттерн, что
-- 175_scrape_schedules_seed_domclick_detail_backfill.sql): это ПЕРВАЯ
-- автоматическая задача физического DELETE персональных данных в trade-in —
-- заслуживает supervised первого прогона (смотри логи/counters вручную)
-- перед тем, как доверить её расписанию.
--
-- ⚠️ ПЕРЕД ВКЛЮЧЕНИЕМ (deep-review 2026-08-06, HIGH — читай целиком, не
-- только команду в конце): задача удаляет строки trade_in_estimates
-- физически и НЕОБРАТИМО. DELETE ограничен `created_by IS NULL` — только
-- анонимные B2C-оценки (см. докстринг app/tasks/purge_expired_trade_in_
-- data.py, раздел про асимметрию expires_at = TTL ссылки/PDF, а не срок
-- хранения строки) — строки B2B-пилотов (`created_by` задан) задача НЕ
-- трогает НИКОГДА, что бы ни стояло в expires_at. Аудит на проде на момент
-- этой правки: из 1057 строк trade_in_estimates 1040 уже просрочены
-- (expires_at < NOW()), но ТОЛЬКО 129 из них имеют created_by IS NULL
-- (реальная B2C-популяция под удаление) — остальные 911 принадлежат
-- пилотам (admin/kopylov/brusnika/praktika/pilottest/admintest/user1) и
-- гвардом защищены от удаления. Эти цифры УСТАРЕЮТ — не включай задачу по
-- их памяти. Перед `UPDATE scrape_schedules SET enabled=true WHERE
-- source='purge_expired_trade_in_data'`:
-- 1. Свежий `SELECT count(*) FROM trade_in_estimates WHERE expires_at
-- < NOW() AND created_by IS NULL` — если число на порядок больше
-- 129 (или created_by-гвард почему-то отсутствует в текущем коде
-- задачи) — СТОП, разберись, прежде чем включать.
-- 2. Прогони задачу вручную ОДИН раз (app/tasks/
-- purge_expired_trade_in_data.py, синхронная функция) и сверь
-- counters с п.1 (задача логирует batch/running_total через
-- logger.info в _drain_expired) — supervised первый прогон, не
-- включение вслепую.
-- 3. Только после совпадения счётчиков — enable в scrape_schedules;
-- расписание подхватит на следующем тике планировщика (крутится в
-- контейнере tradein-scraper, не tradein-backend).
-- Включение — отдельный ручной шаг, эта миграция его НЕ делает.
--
-- IDEMPOTENCY / SAFETY:
-- - ADD COLUMN IF NOT EXISTS + UPDATE ... WHERE expires_at IS NULL (no-op на
-- повторном прогоне, все строки уже проставлены) +
-- ALTER COLUMN ... SET NOT NULL (идемпотентно само по себе — Postgres не
-- ошибается на повторной установке уже действующего NOT NULL).
-- - CREATE INDEX IF NOT EXISTS — безопасный re-run.
-- - INSERT ... ON CONFLICT (source) DO NOTHING — безопасный re-run seed'а.
--
-- Dependencies: 172_trade_in_leads.sql (таблица), 052_scrape_schedules.sql
-- (scrape_schedules), 229_trade_in_estimates_consent_proof.sql (соседняя
-- часть той же ЭТАП 4 инициативы — индекс на trade_in_estimates.expires_at
-- уже создан там).
BEGIN;
ALTER TABLE trade_in_leads
ADD COLUMN IF NOT EXISTS expires_at timestamptz;
UPDATE trade_in_leads
SET expires_at = created_at + interval '180 days'
WHERE expires_at IS NULL;
ALTER TABLE trade_in_leads
ALTER COLUMN expires_at SET NOT NULL;
CREATE INDEX IF NOT EXISTS trade_in_leads_expires_at_idx
ON trade_in_leads (expires_at);
COMMENT ON COLUMN trade_in_leads.expires_at IS 'ЭТАП 4 B2C (152-ФЗ): срок хранения лида. Backfill для legacy-строк = created_at + 180 дней; новые строки считаются на insert-time из settings.trade_in_lead_retention_days (app/api/v1/lead.py). Физическое удаление после истечения — app/tasks/purge_expired_trade_in_data.py.';
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'purge_expired_trade_in_data',
false,
2,
4,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
'{"batch_size": 500, "max_batches": 20}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMENT ON TABLE scrape_schedules IS
'In-app scheduler config (replaces cron-script setup). Sources: avito_city_sweep, yandex_city_sweep (dormant, #561), cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot (#570), asking_to_sold_ratio_refresh (#648), refresh_search_matview (#769), yandex_address_backfill (#855, EKB pilot), sber_index_pull (#887, monthly), rosreestr_quarter_poll (#888, monthly), cian_city_sweep (dormant, #973), yandex_newbuilding_sweep (dormant, #974), geocode_missing_listings (#1: listings geom backfill, all sources), avito_detail_backfill (#1551: nightly detail-enrichment backfill for legacy avito listings), domclick_detail_backfill (#2000: nightly Layer B detail-enrichment backfill for domklik listings, cookie-injection + QRATOR-aware, disabled by default until smoke-tested), purge_expired_trade_in_data (ЭТАП 4 B2C: nightly batched physical DELETE of expired trade_in_estimates/trade_in_leads rows, disabled by default until a supervised first run).';
COMMIT;