gendesign/tradein-mvp/backend/data/sql/193_trade_in_privacy_retention.sql
bot-backend 5626d9e720
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m11s
feat(mera/b2c): правовая рамка — согласие до сохранения, удаление по сроку и по запросу (этап 4 из 8)
Три дефекта, каждый блокировал легальный публичный запуск.

1. Адрес физлица сохранялся в базу ДО любого согласия: согласие фиксировалось
   только на форме заявки, то есть ПОСЛЕ записи адреса. Для пилота с договором
   терпимо, для человека с улицы — нет. Проверка согласия поставлена первой
   строкой расчёта, до геокодирования и до обоих мест записи адреса.

   Хранение — колонками на самой оценке, 1:1 с уже работающим прецедентом для
   заявок (миграция 182): IP клиента, версия политики, дословный снимок текста.
   Отдельная таблица событий не заводилась: согласие даётся ровно на создание
   этой строки, и когда строка удаляется по сроку, исчезновение доказательства
   вместе с данными логично.

   Enforcement НЕ выводится из пустого created_by — первая версия так и делала
   и сломала 92 несвязанных теста оценщика, которые зовут расчёт без имени
   пользователя, проверяя ценовую логику. Вместо этого явный флаг, который
   выставляет единственный боевой вызывающий. B2B-поток не тронут: поле
   согласия опционально, иначе сломались бы пилоты, чей фронт его не шлёт.

2. Срок жизни оценки применялся только как фильтр при чтении — физического
   удаления не было ни в одной фоновой задаче, данные жили вечно вопреки
   декларированному сроку. Заведена задача удаления пачками с ограничением на
   прогон и коммитом после каждой пачки, идемпотентная. В расписании она
   ВЫКЛЮЧЕНА: это первая автоматическая задача, удаляющая персональные данные,
   и первый прогон должен быть под наблюдением.

3. Пути «удалите мои данные» не было. Добавлен сервис удаления и админская
   ручка. Ключи: имя пользователя, идентификатор оценки, телефон, чат в
   телеграме.

   Честно зафиксировано в коде: аноним без ссылки на оценку, без оставленного
   телефона и без обращения в поддержку неидентифицируем — удалить его данные
   без дополнительной идентификации нельзя. Отдельно: удаление чистит только
   копию в базе, зеркало переписки в телеграм-топике не удаляется ничем в
   кодовой базе, нужен ручной шаг.

4. Соответствие текста согласия на фронте и снимка на бэке держалось на
   комментарии. Теперь есть тест, который ловит расхождение.

Сроки хранения вынесены в настройки. Значение для заявок предложено инженерно
(типичный отраслевой диапазон), юридически обоснованный срок — за юристом, и
это записано в коде.

Тесты: 2775 passed.
2026-07-28 15:24:21 +03:00

93 lines
6.2 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.

-- 193_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 вручную)
-- перед тем, как доверить её расписанию. Включение — отдельный ручной шаг
-- (UPDATE scrape_schedules SET enabled=true WHERE source=
-- 'purge_expired_trade_in_data').
--
-- 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), 192_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;