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
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 тесты.
119 lines
8.7 KiB
PL/PgSQL
119 lines
8.7 KiB
PL/PgSQL
-- 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;
|