Files
portal/app/app/Services/Pd/PdErasureService.php
T
Дмитрий 6e1f5355b8 refactor(webhook): Phase 4 — DROP migration + schema v8.35 + test/factory cleanup
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>
2026-05-24 18:51:17 +03:00

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,
]);
}
}