6e1f5355b8
Task 4.1 Steps 1–7: legacy direct webhook channel DDL removal.
Migration 2026_05_24_140000_drop_legacy_webhook_artefacts:
- DROP TABLE webhook_log CASCADE (partitioned RANGE по received_at)
- DROP TABLE rejected_deals_log CASCADE
- ALTER TABLE tenants DROP COLUMN webhook_token, webhook_token_rotated_at
- DELETE FROM system_settings WHERE key = 'low_balance_threshold_leads'
NB: webhook_dedup_keys ОСТАВЛЕНА — используется CSV-каналом (HistoricalImportService).
Services fixed (не покрыты Phase 3):
- MonthlyPartitionManager::PARTITIONED_TABLES — убрана строка webhook_log
- PdErasureService::eraseSubject() — убрана секция 4 (SELECT/UPDATE webhook_log)
Factory + tests cleanup (webhook_token column gone):
- TenantFactory: убрано webhook_token из definition()
- 7 test files: убраны вставки webhook_token в DB::table('tenants')->insert(...)
- storage/_demo_split_tenants.php: убрана строка webhook_token
Schema v8.35:
- −2 таблицы (webhook_log partitioned + rejected_deals_log)
- −5 индексов (idx_webhook_log_*, idx_rejected_*, idx_tenants_webhook_token)
- −2 RLS-политики
- db/CHANGELOG_schema.md: запись v8.35
Tests updated:
- SchemaDeltaTest: 66 base tables / 120 indexes / 40 RLS policies
- PartitionsCreateMonthsTest: webhook_log убрана из regex / 48 skipped вместо 54
Smoke: 36/36 passed (RlsSmoke, AdminBilling, AdminPdSubject, PartitionsCreateMonths, SchemaDelta).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
220 lines
8.9 KiB
PHP
220 lines
8.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Pd;
|
|
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Support\Facades\DB;
|
|
use InvalidArgumentException;
|
|
|
|
/**
|
|
* Сервис анонимизации ПДн субъекта по 152-ФЗ (право на удаление, ст.21).
|
|
*
|
|
* Использует соединение pgsql_supplier (BYPASSRLS / crm_supplier_worker),
|
|
* чтобы читать и писать cross-tenant без RLS-ограничений.
|
|
*
|
|
* Реальные колонки схемы v8.19:
|
|
* 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 удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
|
|
*/
|
|
class PdErasureService
|
|
{
|
|
private const DB = 'pgsql_supplier';
|
|
|
|
/**
|
|
* Анонимизировать все ПДн субъекта по email и/или телефону.
|
|
*
|
|
* @param string|null $email Email субъекта (один из двух обязателен)
|
|
* @param string|null $phone Телефон субъекта (один из двух обязателен)
|
|
* @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}
|
|
*
|
|
* @throws InvalidArgumentException если оба email и phone null
|
|
*/
|
|
public function eraseSubject(
|
|
?string $email,
|
|
?string $phone,
|
|
?int $tenantId,
|
|
int $actorAdminId,
|
|
?string $requestId = null,
|
|
): array {
|
|
if ($email === null && $phone === null) {
|
|
throw new InvalidArgumentException('Необходимо указать email или телефон субъекта.');
|
|
}
|
|
|
|
$counts = ['users' => 0, 'leads' => 0, 'deals' => 0];
|
|
|
|
DB::connection(self::DB)->transaction(function () use (
|
|
$email, $phone, $tenantId, $actorAdminId, $requestId, &$counts
|
|
): void {
|
|
$now = CarbonImmutable::now();
|
|
|
|
// ------------------------------------------------------------------
|
|
// 1. users
|
|
// ------------------------------------------------------------------
|
|
$userQuery = DB::connection(self::DB)->table('users');
|
|
$userQuery->where(function ($q) use ($email, $phone): void {
|
|
if ($email !== null) {
|
|
$q->orWhere('email', $email);
|
|
}
|
|
if ($phone !== null) {
|
|
$q->orWhere('phone', $phone);
|
|
}
|
|
});
|
|
if ($tenantId !== null) {
|
|
$userQuery->where('tenant_id', $tenantId);
|
|
}
|
|
|
|
$users = $userQuery->get(['id', 'tenant_id']);
|
|
|
|
foreach ($users as $user) {
|
|
$userId = (int) $user->id;
|
|
$userTenantId = (int) $user->tenant_id;
|
|
|
|
DB::connection(self::DB)->table('users')
|
|
->where('id', $userId)
|
|
->update([
|
|
'email' => 'erased-'.$userId.'@deleted.local',
|
|
'first_name' => 'Удалено',
|
|
'last_name' => null,
|
|
'phone' => '+7000'.str_pad((string) $userId, 7, '0', STR_PAD_LEFT),
|
|
'updated_at' => $now,
|
|
]);
|
|
|
|
$this->writePdLog(
|
|
tenantId: $userTenantId,
|
|
subjectType: 'user',
|
|
subjectId: $userId,
|
|
actorAdminId: $actorAdminId,
|
|
now: $now,
|
|
);
|
|
}
|
|
|
|
$counts['users'] = $users->count();
|
|
|
|
// ------------------------------------------------------------------
|
|
// 2. supplier_leads (phone + raw_payload JSONB)
|
|
// NB: нет contact_email / contact_phone — поиск только по phone
|
|
// ------------------------------------------------------------------
|
|
$leadQuery = DB::connection(self::DB)->table('supplier_leads');
|
|
if ($phone !== null) {
|
|
$leadQuery->where('phone', $phone);
|
|
} else {
|
|
// Только email — ищем в raw_payload JSONB
|
|
$leadQuery->whereRaw('raw_payload::text LIKE ?', ['%'.$email.'%']);
|
|
}
|
|
|
|
$leads = $leadQuery->get(['id']);
|
|
|
|
foreach ($leads as $lead) {
|
|
$leadId = (int) $lead->id;
|
|
|
|
DB::connection(self::DB)->table('supplier_leads')
|
|
->where('id', $leadId)
|
|
->update([
|
|
'phone' => '+7000XXXXXXX',
|
|
'raw_payload' => DB::connection(self::DB)->raw(
|
|
"JSONB_BUILD_OBJECT('erased', TRUE, 'erased_at', NOW()::TEXT)"
|
|
),
|
|
]);
|
|
|
|
$this->writePdLog(
|
|
tenantId: $tenantId,
|
|
subjectType: 'lead',
|
|
subjectId: $leadId,
|
|
actorAdminId: $actorAdminId,
|
|
now: $now,
|
|
);
|
|
}
|
|
|
|
$counts['leads'] = $leads->count();
|
|
|
|
// ------------------------------------------------------------------
|
|
// 3. deals (phone + contact_name)
|
|
// Deals партиционированы — UPDATE без WHERE на партиции через
|
|
// parent table работает начиная с PG 11+.
|
|
// ------------------------------------------------------------------
|
|
$dealQuery = DB::connection(self::DB)->table('deals');
|
|
$dealQuery->where(function ($q) use ($email, $phone): void {
|
|
if ($phone !== null) {
|
|
$q->orWhere('phone', $phone);
|
|
}
|
|
if ($email !== null) {
|
|
// Дополнительно: UTM/phones JSONB может хранить email, но в
|
|
// минимуме ищем только по phone. Email в deals не хранится
|
|
// в отдельной колонке.
|
|
}
|
|
});
|
|
if ($tenantId !== null) {
|
|
$dealQuery->where('tenant_id', $tenantId);
|
|
}
|
|
// Исключаем строки без совпадения по phone (когда phone=null — ничего не ищем)
|
|
if ($phone === null) {
|
|
// deals не имеет email-колонки, пропускаем
|
|
$dealQuery->whereRaw('FALSE');
|
|
}
|
|
|
|
$deals = $dealQuery->get(['id']);
|
|
|
|
foreach ($deals as $deal) {
|
|
$dealId = (int) $deal->id;
|
|
|
|
DB::connection(self::DB)->table('deals')
|
|
->where('id', $dealId)
|
|
->update([
|
|
'phone' => '+7000XXXXXXX',
|
|
'contact_name' => 'Удалено',
|
|
'updated_at' => $now,
|
|
]);
|
|
}
|
|
|
|
$counts['deals'] = $deals->count();
|
|
|
|
// ------------------------------------------------------------------
|
|
// 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']}";
|
|
|
|
DB::connection(self::DB)->table('pd_subject_requests')
|
|
->where('id', $requestId)
|
|
->update([
|
|
'status' => 'completed',
|
|
'completed_at' => $now,
|
|
'response_text' => $summary,
|
|
]);
|
|
}
|
|
});
|
|
|
|
return $counts;
|
|
}
|
|
|
|
/**
|
|
* Вставить запись в pd_processing_log через BYPASSRLS-соединение.
|
|
*/
|
|
private function writePdLog(
|
|
?int $tenantId,
|
|
string $subjectType,
|
|
int $subjectId,
|
|
int $actorAdminId,
|
|
CarbonImmutable $now,
|
|
): void {
|
|
DB::connection(self::DB)->table('pd_processing_log')->insert([
|
|
'tenant_id' => $tenantId,
|
|
'subject_type' => $subjectType,
|
|
'subject_id' => $subjectId,
|
|
'action' => 'deleted',
|
|
'purpose' => '152-FZ erasure',
|
|
'actor_admin_user_id' => $actorAdminId,
|
|
'created_at' => $now,
|
|
]);
|
|
}
|
|
}
|