harden(supplier): защита-инвариант «телефон звонившего != номер-ловушка» + робастный парсер
Defense-in-depth после 2-го инцидента в csv_recovery-канале (16.07.2026): - RouteSupplierLeadJob: чокпоинт обоих путей (webhook+csv_recovery) — если у звонкового сигнала phone == identifier (номер-ловушка проекта), сделка НЕ создаётся и клиент НЕ списывается; лид метится processed_at+error, шлётся warning. Ловит любой будущий регресс. - SupplierPortalClient.parseDeliveredRows: извлечение номера проекта без якоря $ — ловит номер-ловушку даже с хвостовыми символами. Тесты RED->GREEN (guard 5 assertions), 45/45 route+csv, Larastan 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -148,6 +148,28 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
$projectField = (string) ($lead->raw_payload['project'] ?? '');
|
||||
[$platform, $signalType, $identifier] = $this->parseProjectField($projectField);
|
||||
|
||||
// Defense-in-depth (2-й инцидент в csv_recovery-канале, 16.07.2026): у звонкового
|
||||
// сигнала телефон звонившего НИКОГДА не равен номеру-ловушке проекта ($identifier).
|
||||
// Равенство = подпись порчи данных (регресс HTML-парсера «Мои сделки» и т.п.) в ЛЮБОМ
|
||||
// канале/пути. Не создаём сделку и не биллим клиента за мусорный номер; метим лид
|
||||
// (processed_at, чтобы не было retry-шторма) и шлём warning, чтобы регресс не спрятался.
|
||||
if ($signalType === 'call' && (string) $lead->phone === $identifier) {
|
||||
$lead->update([
|
||||
'processed_at' => now(),
|
||||
'deals_created_count' => 0,
|
||||
'error' => 'caller_phone_equals_tracking_number [rejected by RouteSupplierLeadJob]',
|
||||
]);
|
||||
Log::warning('supplier_lead.caller_equals_tracking_number', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'vid' => $lead->vid,
|
||||
'identifier' => $identifier,
|
||||
'source' => $lead->source,
|
||||
'platform' => $platform,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$supplier = $resolver->resolveOrStub($platform, $signalType, $identifier);
|
||||
$lead->update(['supplier_project_id' => $supplier->id]);
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ class SupplierPortalClient
|
||||
// «Мои сделки» идёт РАНЬШЕ телефона звонившего. Берём первый 7\d{10}, НЕ равный
|
||||
// номеру проекта, иначе номер-ловушка попадёт в сделку вместо звонившего
|
||||
// (прод-инцидент 16.07.2026, tenant 7 — 11 сделок с номером проекта).
|
||||
$projectNumber = preg_match('/^B[123]_(7\d{10})$/', $project, $pn) === 1 ? $pn[1] : null;
|
||||
$projectNumber = preg_match('/^B[123]_(7\d{10})/', $project, $pn) === 1 ? $pn[1] : null;
|
||||
|
||||
if (preg_match_all('/(7\d{10})/', $row, $phones) < 1) {
|
||||
continue;
|
||||
|
||||
@@ -65,6 +65,55 @@ it('is terminal (does not throw / re-queue) when the supplier lead does not exis
|
||||
expect(DB::table('deals')->count())->toBe($countBefore);
|
||||
});
|
||||
|
||||
it('rejects a call lead whose phone equals the tracking number (corruption guard: no deal, no charge)', function (): void {
|
||||
// Defense-in-depth после прод-инцидента 16.07.2026 (2-й инцидент в csv_recovery-канале):
|
||||
// у звонкового сигнала телефон звонившего НИКОГДА не равен номеру-ловушке проекта.
|
||||
// Равенство = подпись порчи (регресс HTML-парсера и т.п.) → не создаём сделку, не биллим.
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'call',
|
||||
'unique_key' => '74950009988',
|
||||
]);
|
||||
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $t->id,
|
||||
'supplier_b2_project_id' => $supplier->id,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '74950009988',
|
||||
'is_active' => true,
|
||||
'delivered_today' => 0,
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
createRoutingSnapshotFromProject($project);
|
||||
|
||||
$vid = 555000111;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B2',
|
||||
'vid' => $vid,
|
||||
'phone' => '74950009988', // == номер-ловушка (подпись порчи)
|
||||
'source' => 'csv_recovery',
|
||||
'raw_payload' => [
|
||||
'vid' => $vid,
|
||||
'project' => 'B2_74950009988',
|
||||
'phone' => '74950009988',
|
||||
],
|
||||
]);
|
||||
|
||||
$dealsBefore = DB::table('deals')->count();
|
||||
$chargesBefore = DB::table('lead_charges')->count();
|
||||
|
||||
runRouteJob($lead->id);
|
||||
|
||||
$lead->refresh();
|
||||
expect(DB::table('deals')->count())->toBe($dealsBefore); // сделка НЕ создана
|
||||
expect(DB::table('lead_charges')->count())->toBe($chargesBefore); // не списано
|
||||
expect($lead->processed_at)->not->toBeNull(); // помечен (нет retry-шторма)
|
||||
expect($lead->deals_created_count)->toBe(0);
|
||||
expect($lead->error)->toContain('caller_phone_equals_tracking_number');
|
||||
});
|
||||
|
||||
it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', function (): void {
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
|
||||
Reference in New Issue
Block a user