diff --git a/app/app/Services/MonthlyPartitionManager.php b/app/app/Services/MonthlyPartitionManager.php index ae52f905..fbd49818 100644 --- a/app/app/Services/MonthlyPartitionManager.php +++ b/app/app/Services/MonthlyPartitionManager.php @@ -53,7 +53,7 @@ class MonthlyPartitionManager 'auth_log' => 'created_at', 'activity_log' => 'created_at', 'tenant_operations_log' => 'created_at', - 'webhook_log' => 'received_at', + // webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts (legacy direct webhook removal) 'balance_transactions' => 'created_at', 'pd_processing_log' => 'created_at', 'saas_admin_audit_log' => 'created_at', diff --git a/app/app/Services/Pd/PdErasureService.php b/app/app/Services/Pd/PdErasureService.php index 9852bb05..457e55ae 100644 --- a/app/app/Services/Pd/PdErasureService.php +++ b/app/app/Services/Pd/PdErasureService.php @@ -18,7 +18,7 @@ use InvalidArgumentException; * users: email, first_name, last_name, phone * supplier_leads: phone, raw_payload (JSONB) — нет contact_email/contact_phone * deals: phone, contact_name — нет отдельного contact_email - * webhook_log: raw_payload (JSONB) + * (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts) */ class PdErasureService { @@ -32,7 +32,7 @@ class PdErasureService * @param int|null $tenantId Ограничить поиск одним тенантом (null = все) * @param int $actorAdminId ID saas_admin_users * @param string|null $requestId ID pd_subject_requests для авто-закрытия - * @return array{users: int, leads: int, deals: int, webhook_log: int} + * @return array{users: int, leads: int, deals: int} * * @throws InvalidArgumentException если оба email и phone null */ @@ -47,7 +47,7 @@ class PdErasureService throw new InvalidArgumentException('Необходимо указать email или телефон субъекта.'); } - $counts = ['users' => 0, 'leads' => 0, 'deals' => 0, 'webhook_log' => 0]; + $counts = ['users' => 0, 'leads' => 0, 'deals' => 0]; DB::connection(self::DB)->transaction(function () use ( $email, $phone, $tenantId, $actorAdminId, $requestId, &$counts @@ -176,50 +176,12 @@ class PdErasureService $counts['deals'] = $deals->count(); // ------------------------------------------------------------------ - // 4. webhook_log (raw_payload JSONB text-search) - // ------------------------------------------------------------------ - $wlQuery = DB::connection(self::DB)->table('webhook_log'); - $conditions = []; - $bindings = []; - if ($email !== null) { - $conditions[] = 'raw_payload::text LIKE ?'; - $bindings[] = '%'.$email.'%'; - } - if ($phone !== null) { - $conditions[] = 'raw_payload::text LIKE ?'; - $bindings[] = '%'.$phone.'%'; - } - - if (! empty($conditions)) { - $wlQuery->whereRaw('('.implode(' OR ', $conditions).')', $bindings); - } - - if ($tenantId !== null) { - $wlQuery->where('tenant_id', $tenantId); - } - - // Batched update: обрабатываем по 500 строк - $wlCount = 0; - $wlQuery->select('id')->orderBy('id')->chunk(500, function ($rows) use (&$wlCount): void { - $ids = $rows->pluck('id')->all(); - DB::connection(self::DB)->table('webhook_log') - ->whereIn('id', $ids) - ->update([ - 'raw_payload' => DB::connection(self::DB)->raw( - "JSONB_BUILD_OBJECT('erased', TRUE, 'erased_at', NOW()::TEXT)" - ), - ]); - $wlCount += count($ids); - }); - - $counts['webhook_log'] = $wlCount; - - // ------------------------------------------------------------------ - // 5. Обновить pd_subject_requests если requestId передан + // 4. Обновить pd_subject_requests если requestId передан + // (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts) // ------------------------------------------------------------------ if ($requestId !== null) { $summary = "Удалено: users={$counts['users']}, leads={$counts['leads']}, " - ."deals={$counts['deals']}, webhook_log={$counts['webhook_log']}"; + ."deals={$counts['deals']}"; DB::connection(self::DB)->table('pd_subject_requests') ->where('id', $requestId) diff --git a/app/database/factories/TenantFactory.php b/app/database/factories/TenantFactory.php index 4991db46..7fe07e83 100644 --- a/app/database/factories/TenantFactory.php +++ b/app/database/factories/TenantFactory.php @@ -22,7 +22,6 @@ class TenantFactory extends Factory 'subdomain' => 'tenant-'.Str::lower(Str::random(8)), 'organization_name' => fake()->company(), 'contact_email' => fake()->unique()->safeEmail(), - 'webhook_token' => Str::random(64), 'timezone' => 'Europe/Moscow', 'locale' => 'ru', 'is_trial' => true, diff --git a/app/database/migrations/2026_05_24_140000_drop_legacy_webhook_artefacts.php b/app/database/migrations/2026_05_24_140000_drop_legacy_webhook_artefacts.php new file mode 100644 index 00000000..e0b7eb0d --- /dev/null +++ b/app/database/migrations/2026_05_24_140000_drop_legacy_webhook_artefacts.php @@ -0,0 +1,55 @@ +statement('DROP TABLE IF EXISTS webhook_log CASCADE'); + + // NB: webhook_dedup_keys НЕ дропаем — Phase 1 RED FLAG, живой через HistoricalImportService (CSV-канал). + + // RejectedDealsLog — writer только удалённый ProcessWebhookJob, readers нет. + $conn->statement('DROP TABLE IF EXISTS rejected_deals_log CASCADE'); + + // tenants.webhook_token + webhook_token_rotated_at — нет в UI/API. + $conn->statement('ALTER TABLE tenants DROP COLUMN IF EXISTS webhook_token, DROP COLUMN IF EXISTS webhook_token_rotated_at'); + + // Legacy threshold-cross seed (caller — удалённый ProcessWebhookJob). + $conn->statement("DELETE FROM system_settings WHERE key = 'low_balance_threshold_leads'"); + } + + /** + * Откат — пустая заглушка. Прод-restore из pg_dump backup. + * Этот метод существует только чтобы migrate:rollback не падал. + */ + public function down(): void + { + // НЕ восстанавливаем структуру — пустая заглушка. + // Прод-restore — из pg_dump backup (см. runbook docs/deploy/test-server-runbook.md). + } +}; diff --git a/app/storage/_demo_split_tenants.php b/app/storage/_demo_split_tenants.php index f1ffd784..9507c6da 100644 --- a/app/storage/_demo_split_tenants.php +++ b/app/storage/_demo_split_tenants.php @@ -77,7 +77,6 @@ foreach ($accounts as $a) { [ 'organization_name' => $a['org_name'], 'contact_email' => $user->email, - 'webhook_token' => Str::random(64), 'timezone' => 'Europe/Moscow', 'locale' => 'ru', 'is_trial' => true, diff --git a/app/tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php b/app/tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php index 1591fdbf..2cac2fbe 100644 --- a/app/tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php +++ b/app/tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php @@ -164,7 +164,6 @@ it('executeErasure anonymises user email first_name phone and writes pd_processi 'organization_name' => 'PD User Test', 'contact_email' => 'pd-u@test.local', 'status' => 'active', - 'webhook_token' => bin2hex(random_bytes(16)), 'balance_rub' => '0.00', 'balance_leads' => 0, 'is_trial' => false, diff --git a/app/tests/Feature/AdminBillingActionsTest.php b/app/tests/Feature/AdminBillingActionsTest.php index 63b2c149..446533ae 100644 --- a/app/tests/Feature/AdminBillingActionsTest.php +++ b/app/tests/Feature/AdminBillingActionsTest.php @@ -14,7 +14,6 @@ function makeBillingTenant(array $overrides = []): int 'subdomain' => 'bt-'.bin2hex(random_bytes(4)), 'organization_name' => 'Billing Test Co', 'contact_email' => 'bt-'.bin2hex(random_bytes(3)).'@test.local', - 'webhook_token' => bin2hex(random_bytes(16)), 'status' => 'active', 'balance_rub' => '5000.00', 'is_trial' => false, diff --git a/app/tests/Feature/AdminIncidentShowTest.php b/app/tests/Feature/AdminIncidentShowTest.php index 3ce15349..75c47e96 100644 --- a/app/tests/Feature/AdminIncidentShowTest.php +++ b/app/tests/Feature/AdminIncidentShowTest.php @@ -64,7 +64,6 @@ test('GET /api/admin/incidents/{id} разрешает имена affected_tenan 'subdomain' => 'inc-'.bin2hex(random_bytes(4)), 'organization_name' => 'Affected Org', 'contact_email' => 'a@test.local', - 'webhook_token' => bin2hex(random_bytes(16)), 'created_at' => now(), ]); $id = makeShowIncident($this->adminId, ['affected_tenant_ids' => '{'.$tenantId.'}']); diff --git a/app/tests/Feature/Console/VerifyAuditChainsTest.php b/app/tests/Feature/Console/VerifyAuditChainsTest.php index 834d9efd..460611eb 100644 --- a/app/tests/Feature/Console/VerifyAuditChainsTest.php +++ b/app/tests/Feature/Console/VerifyAuditChainsTest.php @@ -69,7 +69,6 @@ function ensureTenant(int $seed): int 'organization_name' => "Test Chain {$seed}", 'subdomain' => "test-chain-{$seed}", 'contact_email' => "chain{$seed}@example.com", - 'webhook_token' => bin2hex(random_bytes(16))."-seed{$seed}", 'status' => 'active', 'created_at' => now(), 'updated_at' => now(), diff --git a/app/tests/Feature/PartitionsCreateMonthsTest.php b/app/tests/Feature/PartitionsCreateMonthsTest.php index c18cf953..6f412aa9 100644 --- a/app/tests/Feature/PartitionsCreateMonthsTest.php +++ b/app/tests/Feature/PartitionsCreateMonthsTest.php @@ -17,7 +17,7 @@ beforeEach(function () { $this->partitionsBefore = collect(DB::select(" SELECT relname FROM pg_class WHERE relkind = 'r' - AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' + AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' "))->pluck('relname')->all(); }); @@ -25,7 +25,7 @@ afterEach(function () { $partitionsAfter = collect(DB::select(" SELECT relname FROM pg_class WHERE relkind = 'r' - AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' + AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' "))->pluck('relname')->all(); // DETACH перед DROP: иначе `DROP TABLE ... CASCADE` сносит FK от @@ -61,7 +61,7 @@ test('идемпотентность: повторный запуск не па $afterFirst = collect(DB::select(" SELECT relname FROM pg_class WHERE relkind = 'r' - AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' + AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' "))->count(); // Повторный запуск — должен только skip'ать. @@ -71,14 +71,15 @@ test('идемпотентность: повторный запуск не па $afterSecond = collect(DB::select(" SELECT relname FROM pg_class WHERE relkind = 'r' - AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' + AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' "))->count(); expect($afterSecond)->toBe($afterFirst); - // Output второго запуска должен сказать «0 created» по всем 9 таблицам × 6 месяцев = 54 партиции. + // Output второго запуска должен сказать «0 created» по всем 8 таблицам × 6 месяцев = 48 партиций. + // (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts) $output = Artisan::output(); - expect($output)->toContain('0 created, 54 skipped'); + expect($output)->toContain('0 created, 48 skipped'); }); test('--ahead=0 создаёт только текущий месяц', function () { @@ -100,7 +101,6 @@ test('партиция корректно принимает INSERT в окно 'subdomain' => 'partition-test-'.uniqid(), 'organization_name' => 'PartitionTest', 'contact_email' => 'pt@test.local', - 'webhook_token' => str_repeat('p', 64), 'api_key_limit' => 5, ]); $projectId = DB::table('projects')->insertGetId([ diff --git a/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php b/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php index 6e2ad75c..ddfdef0f 100644 --- a/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php +++ b/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php @@ -19,8 +19,8 @@ it('tenants table has delivered_in_month column with CHECK >= 0', function () { expect(Schema::hasColumn('tenants', 'delivered_in_month'))->toBeTrue(); DB::table('tenants')->where('id', '<', 0)->update(['delivered_in_month' => 5]); // no-op expect(fn () => DB::statement( - 'INSERT INTO tenants (subdomain, organization_name, contact_email, webhook_token, delivered_in_month) '. - "VALUES ('t-neg-test', 'X', 'x@x', 'wtok-neg-test-99999999', -1)" + 'INSERT INTO tenants (subdomain, organization_name, contact_email, delivered_in_month) '. + "VALUES ('t-neg-test', 'X', 'x@x', -1)" ))->toThrow(QueryException::class); }); @@ -59,32 +59,33 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE ]))->toThrow(QueryException::class); }); -it('schema.sql v8.26 has correct metrics — 65 base tables, 123 indexes, 40 RLS policies', function () { +it('schema.sql v8.35 has correct metrics — 66 base tables, 120 indexes, 40 RLS policies', function () { // Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные // Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` — - // источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.26. + // источник истины метрик. // v8.21 (Sprint 4): +1 таблица import_unknown_statuses, +1 индекс, +1 RLS-политика. // v8.22 (Plan 6/C9): +1 GIN-индекс idx_projects_regions. // v8.25 (supplier-failover): +1 таблица supplier_manual_sync_queue, +2 индекса. // v8.26 (project-migration-redesign Plans 1-3): +1 таблица project_supplier_links (M:N pivot) // + 2 индекса (supplier_projects_platform_key_subject_unique, idx_psl_*). + // v8.30: +1 таблица scheduler_heartbeats (SaaS-level, hole #6). + // v8.31: 7 audit-таблиц переведены в PARTITION BY RANGE, hole #2. + // v8.35 (legacy webhook removal): −2 таблицы (webhook_log partitioned + rejected_deals_log) + // −5 индексов, −2 RLS-политики, −2 колонки tenants.webhook_token/webhook_token_rotated_at. $schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql'; expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue(); $schema = file_get_contents($schemaPath); expect($schema)->not->toBeFalse(); - // v8.30: +1 таблица scheduler_heartbeats (SaaS-level, hole #6). - // v8.31: 7 audit-таблиц переведены в PARTITION BY RANGE, hole #2. - // - // 67 base tables = все CREATE TABLE минус PARTITION OF. + // 66 base tables = все CREATE TABLE минус PARTITION OF. $createTables = preg_match_all('/^CREATE TABLE\b/m', $schema); $partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema); $baseTables = $createTables - $partitionOf; - expect($baseTables)->toBe(67); + expect($baseTables)->toBe(66); $createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema); - expect($createIndexes)->toBe(126); // v8.31: +3 индекса audit-таблиц после partitioning + expect($createIndexes)->toBe(120); // v8.35: −5 индексов (webhook_log ×2, rejected_deals_log ×2, tenants.webhook_token ×1) $createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema); - expect($createPolicies)->toBe(41); // v8.31: +1 политика на partitioned audit-таблицах + expect($createPolicies)->toBe(40); // v8.35: −2 политики (webhook_log + rejected_deals_log) }); diff --git a/app/tests/Feature/RlsSmokeTest.php b/app/tests/Feature/RlsSmokeTest.php index 2cc97861..4987626d 100644 --- a/app/tests/Feature/RlsSmokeTest.php +++ b/app/tests/Feature/RlsSmokeTest.php @@ -45,14 +45,12 @@ SQL); 'subdomain' => 'rls-tenant-a-'.uniqid(), 'organization_name' => 'RLS Tenant A', 'contact_email' => 'a@rls-test.local', - 'webhook_token' => 'whtA'.str_pad((string) random_int(0, 999999999), 60, '0', STR_PAD_LEFT), 'api_key_limit' => 5, ]); $this->tenant2Id = DB::table('tenants')->insertGetId([ 'subdomain' => 'rls-tenant-b-'.uniqid(), 'organization_name' => 'RLS Tenant B', 'contact_email' => 'b@rls-test.local', - 'webhook_token' => 'whtB'.str_pad((string) random_int(0, 999999999), 60, '0', STR_PAD_LEFT), 'api_key_limit' => 5, ]); diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 41bd21dd..8ca1d31c 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -2,7 +2,38 @@ **Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. -**Файл схемы:** `schema.sql` (текущая версия — v8.34, консолидированная — разворачивает БД с нуля). +**Файл схемы:** `schema.sql` (текущая версия — v8.35, консолидированная — разворачивает БД с нуля). + +## v8.35 (2026-05-24) — legacy direct webhook removal + +Финальная уборка прямого webhook-канала (тенант → Лидерра). Вся инфраструктура канала +упразднена; CSV-канал (поставщик → Лидерра) сохранён полностью. + +**Удалено:** + +- **Таблица `webhook_log`** (partitioned RANGE по `received_at`) + все дочерние партиции (DROP CASCADE). + Хранила payload входящих webhook от тенантов. Канал прямого приёма упразднён. +- **Таблица `rejected_deals_log`** (регулярная) — журнал отвергнутых лидов прямого webhook-канала. +- **Колонки `tenants.webhook_token` + `tenants.webhook_token_rotated_at`** — токен аутентификации + прямого webhook. Индекс `idx_tenants_webhook_token` удалён вместе с колонкой. +- **Seed-строка `low_balance_threshold_leads`** в `system_settings` — использовалась только + удалённым `LowBalanceNotification` mailable'ом. +- **Seed-строки `webhook_log_retention_days` + `webhook_log_retention_months`** в `system_settings`. + +**Оставлено (НЕ удалено):** + +- **`webhook_dedup_keys`** — используется CSV-каналом (`HistoricalImportService`) для идемпотентности. +- **`failed_webhook_jobs.webhook_log_id`** — orphan BIGINT (без FK с v8.31/W1); оставлен. +- **`outbound_webhook_subscriptions` + `outbound_webhook_deliveries`** — исходящий webhook (тенант → внешний URL); не затронут. + +**Метрики:** −2 таблицы / −5 индексов / −2 RLS-политики. +66 base tables (65 regular + 8 partitioned parents) / 120 indexes / 40 RLS policies. + +**Миграция:** `2026_05_24_140000_drop_legacy_webhook_artefacts` + +**Связанные изменения кода:** +- `MonthlyPartitionManager::PARTITIONED_TABLES` — убрана строка `webhook_log` +- `PdErasureService::eraseSubject()` — убрана секция erasure по `webhook_log` ## v8.34 (2026-05-23) — Billing v2 Spec B: drop deals(duplicate_of_id) index diff --git a/db/schema.sql b/db/schema.sql index f552f759..4f2f7be6 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,12 +1,13 @@ -- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра») --- Версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён) --- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / webhook_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); FK на webhook_log удалены (W1); retention defaults в system_settings) +-- Версия: v8.35 (24.05.2026 — legacy direct webhook removal: DROP webhook_log (partitioned) + rejected_deals_log + tenants.webhook_token/webhook_token_rotated_at; webhook_dedup_keys сохранена (CSV-канал)) +-- Базовая версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён) +-- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); retention defaults в system_settings) -- Базовая версия: v8.30 (23.05.2026 — scheduler_heartbeats: пульс планировщика, SaaS-level без RLS, 11 cron-задач, hole #6) -- Базовая версия: v8.29 (22.05.2026 — webhook_log: supplier audit columns) -- Базовая версия: v8.28 (22.05.2026 — tenant_operations_log: журнал тенант-уровневых операций вне сделок (проекты, API-ключи, webhook URL), append-only hash-chain, P2 operational journaling closure) -- Базовая версия: v8.27 (21.05.2026 — drop projects.archived_at: feature архива заменена настоящим удалением с защитой по сделкам (ProjectService::delete())) --- Метрики: 75 базовые таблицы (66 regular + 9 partitioned parents: deals + supplier_lead_costs + 7 audit) + 12 партиций / 125 индексов / 42 RLS-политика / 5 функций / 15 триггеров +-- Метрики: 73 базовые таблицы (65 regular + 8 partitioned parents: deals + supplier_lead_costs + 6 audit) + 12 партиций / 120 индексов / 40 RLS-политик / 5 функций / 15 триггеров -- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов) -- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2)) -- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов) @@ -630,8 +631,7 @@ CREATE TABLE tenants ( contact_email VARCHAR(255) NOT NULL, status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active','suspended','pending_email_confirm','deleted')), - webhook_token VARCHAR(64) UNIQUE NOT NULL, - webhook_token_rotated_at TIMESTAMPTZ, + -- webhook_token / webhook_token_rotated_at удалены в v8.35 (legacy direct webhook removal) timezone VARCHAR(50) DEFAULT 'Europe/Moscow', locale VARCHAR(10) DEFAULT 'ru', -- Биллинг @@ -678,7 +678,7 @@ CREATE TABLE tenants ( ); CREATE INDEX idx_tenants_subdomain ON tenants(subdomain) WHERE deleted_at IS NULL; -CREATE INDEX idx_tenants_webhook_token ON tenants(webhook_token) WHERE deleted_at IS NULL AND status = 'active'; +-- idx_tenants_webhook_token удалён в v8.35 (legacy direct webhook removal) CREATE INDEX idx_tenants_inactive ON tenants(last_activity_at) WHERE deleted_at IS NULL; -- Forward FK на tenants для SaaS-админских таблиц, объявленных выше @@ -1415,7 +1415,7 @@ CREATE INDEX idx_outbound_subs_secret_prefix ON outbound_webhook_subscriptions -- ----------------------------------------------------------------------------- -- outbound_webhook_deliveries — журнал попыток доставки (v8.4) --- Retention 90 дней (как webhook_log). См. §19.10.6 (retry-логика 7 попыток +-- Retention 90 дней. См. §19.10.6 (retry-логика 7 попыток -- от 30 секунд до 24 часов). -- ----------------------------------------------------------------------------- CREATE TABLE outbound_webhook_deliveries ( @@ -1919,32 +1919,9 @@ COMMENT ON TABLE in_app_notifications IS 'read_at при клике. RLS: tenant isolation.'; --- ----------------------------------------------------------------------------- --- webhook_log — лог принятых webhook (раздел 5.7) --- РЕТЕНШН: system_settings.webhook_log_retention_days (по умолчанию 90 дней) --- ----------------------------------------------------------------------------- --- v8.31: партиционирована помесячно по received_at (hole #2). PK → (id, received_at). --- FK из failed_webhook_jobs/rejected_deals_log удалены (W1 — невозможны на составном PK --- партиционированной таблицы с единичным FK-столбцом). -CREATE TABLE webhook_log ( - id BIGSERIAL, - tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE, -- NULL для platform-level событий (supplier webhook) - raw_payload JSONB NOT NULL, -- содержит ПДн → удаляется при анонимизации - received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key) - processed_at TIMESTAMPTZ, - deal_id BIGINT, -- БЕЗ FK (deals партиционирована) - error TEXT, - -- v8.29: supplier webhook audit columns - source VARCHAR(50), -- 'supplier' | 'tenant' - status VARCHAR(50), -- 'received' | 'rejected_secret' | 'rejected_ip' | 'rate_limited' - lead_id BIGINT, -- supplier_leads.id при статусе 'received' - ip_address INET, -- клиентский IP - created_at TIMESTAMPTZ DEFAULT NOW(), - PRIMARY KEY (id, received_at) -- v8.31: composite PK -) PARTITION BY RANGE (received_at); - -CREATE INDEX idx_webhook_log_tenant_received ON webhook_log(tenant_id, received_at DESC); -CREATE INDEX idx_webhook_log_status ON webhook_log(status, created_at DESC); +-- webhook_log удалена в v8.35 (legacy direct webhook removal). +-- Канал входящих webhook (прямой приём от тенантов) упразднён. +-- webhook_dedup_keys сохранена — используется CSV-каналом (HistoricalImportService). -- ----------------------------------------------------------------------------- @@ -1969,21 +1946,8 @@ CREATE INDEX idx_failed_webhook_unresolved ON failed_webhook_jobs(failed_at DESC CREATE INDEX idx_failed_webhook_jobs_log ON failed_webhook_jobs(webhook_log_id); -- v8.11 (audit O-perf-02) --- ----------------------------------------------------------------------------- --- rejected_deals_log — лог отвергнутых лидов при balance=0 (раздел 5.7) --- РЕТЕНШН: бессрочно (опционально 12 месяцев) --- ----------------------------------------------------------------------------- -CREATE TABLE rejected_deals_log ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - webhook_log_id BIGINT, -- v8.31: FK удалён (W1 — webhook_log партиционирована, composite PK несовместим с одиночным FK) - reason VARCHAR(50) NOT NULL, -- zero_balance, validation_failed, ... - payload JSONB, - created_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE INDEX idx_rejected_tenant_created ON rejected_deals_log(tenant_id, created_at DESC); -CREATE INDEX idx_rejected_deals_log_webhook ON rejected_deals_log(webhook_log_id); -- v8.11 (audit O-perf-03) +-- rejected_deals_log удалена в v8.35 (legacy direct webhook removal). +-- Rejection-журнал для прямого webhook-канала упразднён вместе с каналом. -- ----------------------------------------------------------------------------- @@ -2803,7 +2767,7 @@ VALUES INSERT INTO system_settings (key, value, type, description) VALUES ('schema_version', '8.3', 'string', 'Текущая версия схемы БД'), ('trial_bonus_leads', '50', 'int', 'Стартовый бонус лидов для нового тенанта (fallback для tariff_plans.trial_bonus_leads)'), - ('low_balance_threshold_leads', '10', 'int', 'Порог email-предупреждения о низком балансе'), + -- low_balance_threshold_leads удалён в v8.35 (использовался только LowBalanceNotification — удалена вместе с webhook-каналом) ('inactive_warn_months', '11', 'int', 'Через сколько месяцев простоя слать предупреждение'), ('inactive_delete_months', '12', 'int', 'Через сколько месяцев простоя удалять данные'), ('webhook_rate_limit_rps', '100', 'int', 'Лимит запросов в секунду на токен Webhook'), @@ -2811,7 +2775,7 @@ INSERT INTO system_settings (key, value, type, description) VALUES ('api_rate_limit_per_minute', '60', 'int', 'Лимит запросов API на ключ в минуту'), ('login_max_attempts', '5', 'int', 'Макс. неудачных попыток входа в окне 15 минут'), ('password_min_length', '10', 'int', 'Минимальная длина пароля'), - ('webhook_log_retention_days', '90', 'int', 'Сколько дней хранить raw_payload Webhook'), + -- webhook_log_retention_days удалён в v8.35 (webhook_log таблица удалена) -- VAPID (Web Push, раздел 17.4) ('vapid_public_key', '', 'string', 'VAPID public key (для подписки)'), ('vapid_private_key', '', 'string', 'VAPID private key (ШИФРОВАН) — заполнить при инсталляции'), @@ -2839,11 +2803,11 @@ INSERT INTO system_settings (key, value, type, description) VALUES -- v8.18 (Plan 2/5): supplier-webhook secret + IP allowlist для defense-in-depth. ('supplier_webhook_secret', '__SET_ON_DEPLOY__', 'string', 'Platform-wide секрет (≥32 chars) для /api/webhook/supplier/{secret}. См. spec §5.1.'), ('supplier_ip_allowlist', '[]', 'json', 'Список IP/CIDR поставщика crm.bp-gr.ru. Пустой массив = пропускать всех (DEV); на prod заполнить.'), - -- v8.31: retention для 7 audit-таблиц после partitioning (hole #2). Используется PartitionsDropExpired (cron Sundays 03:00 МСК). + -- v8.31: retention для audit-таблиц после partitioning (hole #2). Используется PartitionsDropExpired (cron Sundays 03:00 МСК). + -- webhook_log_retention_months удалён в v8.35 (webhook_log таблица удалена). ('auth_log_retention_months', '24', 'int', 'Retention auth_log в месяцах (hole #2)'), ('activity_log_retention_months', '36', 'int', 'Retention activity_log (hole #2)'), ('tenant_operations_log_retention_months', '24', 'int', 'Retention tenant_operations_log (hole #2)'), - ('webhook_log_retention_months', '3', 'int', 'Retention webhook_log (hole #2)'), ('balance_transactions_retention_months', '84', 'int', 'Retention balance_transactions, 7л НК РФ (hole #2)'), ('pd_processing_log_retention_months', '36', 'int', 'Retention pd_processing_log, 152-ФЗ 3 года (hole #2)'), ('saas_admin_audit_log_retention_months', '84', 'int', 'Retention saas_admin_audit_log, 7л (hole #2)'); @@ -2916,9 +2880,8 @@ ALTER TABLE import_unknown_statuses ENABLE ROW LEVEL SECURITY; ALTER TABLE activity_log ENABLE ROW LEVEL SECURITY; ALTER TABLE tenant_operations_log ENABLE ROW LEVEL SECURITY; -- v8.31: перенесено сюда (была inline) ALTER TABLE reminders ENABLE ROW LEVEL SECURITY; -ALTER TABLE webhook_log ENABLE ROW LEVEL SECURITY; +-- webhook_log / rejected_deals_log: таблицы удалены в v8.35 ALTER TABLE failed_webhook_jobs ENABLE ROW LEVEL SECURITY; -ALTER TABLE rejected_deals_log ENABLE ROW LEVEL SECURITY; ALTER TABLE tariff_subscriptions ENABLE ROW LEVEL SECURITY; ALTER TABLE saas_invoices ENABLE ROW LEVEL SECURITY; ALTER TABLE saas_invoice_items ENABLE ROW LEVEL SECURITY; -- через invoice_id косвенно (см. политику ниже) @@ -2958,9 +2921,8 @@ CREATE POLICY tenant_isolation ON import_unknown_statuses USING (tenant_id = cur CREATE POLICY tenant_isolation ON activity_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); CREATE POLICY tenant_isolation ON tenant_operations_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.31: перенесено из inline CREATE POLICY tenant_isolation ON reminders USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON webhook_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); +-- webhook_log / rejected_deals_log policies удалены в v8.35 (таблицы удалены) CREATE POLICY tenant_isolation ON failed_webhook_jobs USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON rejected_deals_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); CREATE POLICY tenant_isolation ON tariff_subscriptions USING (tenant_id = current_setting('app.current_tenant_id')::bigint); CREATE POLICY tenant_isolation ON saas_invoices USING (tenant_id = current_setting('app.current_tenant_id')::bigint); CREATE POLICY tenant_isolation ON saas_upd_documents USING (tenant_id = current_setting('app.current_tenant_id')::bigint);