fix обзвон: знак задания и номер в теле больше не могут разойтись
Портальный клиент голосового робота считал опознавательный знак задания от ОДНИХ ЦИФР телефона, а в тело задания клал номер КАК ЕСТЬ. Один и тот же человек, записанный `+7 999 000-00-01` и `79990000001`, давал ОДИН знак и РАЗНЫЕ тела. Приёмник робота сравнивает тело дословно и на такую пару отвечает отказом — то есть честный повтор получал отказ вместо звонка. Вторая половина той же беды: знак и телефон приходили в `pozvonit` ДВУМЯ независимыми доводами, и ничто не заставляло их сойтись. Работник очереди, посчитавший знак один раз и перебирающий номера, отправил бы роботу рассогласованную пару, и человеку не позвонили бы молча. Что сделано: - заведено ОДНО место приведения номера — `ObzvonClient::odinVidNomera`; оба потребителя берут номер оттуда: и знак, и тело задания; - `pozvonit` больше НЕ принимает знак снаружи. Он принимает то, из чего знак складывается — арендатор, кампания, номер, попытка, — и складывает знак сам. Рассогласовать нечего: второго номера в клиенте не существует; - отказ по номеру теперь считается по признаку "в номере ноль цифр", а не по `trim`. Раньше строка вроде "абв" проходила дальше и давала ОДИН И ТОТ ЖЕ знак на все такие задания — два разных задания столкнулись бы знаками у робота, и одному из людей не позвонили бы вовсе; - в докблоке `pozvonit` вслух записано следствие решения владельца Р124: номер попытки нельзя прибавлять на исходе, где звонок МОГ состояться, — иначе знак станет другим и защита не сработает именно в том случае, ради которого заведена. Разбор ответа робота, пределы времени, предел одновременных и проверка шифрования Р123 не тронуты. Мерки. Сторожа роняли работой, а не текстом: возвращали беду в код и смотрели на то, что уходит подставному роботу. - вернул в тело номер "как есть" — 4 красных из 55; - вернул знак доводом снаружи — 1 красный из 55, сторож читает доводный ряд метода через `ReflectionMethod`, а не текст файла; - возврат доказан слепком со снятием невидимых знаков конца строки, после возврата 55 из 55 зелёных; - полный прогон на своей базе: 5293 проверки, 5289 зелёных, красных 0, ошибок 0, пропущено 4; арифметика сходится; - `composer stan` — 0 ошибок, `pint --test` — passed. Открытым оставлено и названо в отчёте: `8...` и `+7...` сегодня разные люди для портала; `dopolnitelno` обязано быть одинаковым у повтора; правило счёта попыток не назначено. Отчёт: docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-znak-tela-2026-08-07.md
This commit is contained in:
@@ -61,9 +61,16 @@ use Illuminate\Support\Facades\Log;
|
||||
* что это то же самое задание, а не новое. Знак даёт {@see self::klyuchZadaniya()}:
|
||||
* он ОДИНАКОВ у всех повторов одной и той же попытки и РАЗНЫЙ у разных попыток.
|
||||
*
|
||||
* 🔴 Клиент не выдумывает знак сам и не подставляет случайный. Случайный знак
|
||||
* выглядел бы как защита и не защищал бы ничего — при каждом повторе он был бы
|
||||
* новым. Задание без знака = дверь закрыта + запись в журнал уровня «ошибка».
|
||||
* 🔴 Знак не случайный и не выдуманный: он посчитан от арендатора, кампании,
|
||||
* номера и числа попытки. Случайный выглядел бы как защита и не защищал бы
|
||||
* ничего — при каждом повторе он был бы новым.
|
||||
*
|
||||
* 🔴 И знак не приходит СНАРУЖИ. {@see self::pozvonit()} принимает то, ИЗ ЧЕГО
|
||||
* знак складывается, и считает его сам, а номер и для знака, и для тела берёт из
|
||||
* одного места ({@see self::odinVidNomera()}). Пока знак и телефон были двумя
|
||||
* независимыми доводами, ничто не заставляло их сойтись: рассогласованная пара
|
||||
* доезжала до робота, робот отвечал отказом, и человеку не звонили МОЛЧА.
|
||||
* Теперь рассогласовать нечего — второго номера в этом клиенте не существует.
|
||||
*
|
||||
* 🪤 Чего эта половина НЕ доказывает: что робот знак и вправду читает. Приёмник
|
||||
* (З-0.6) обязан хранить знаки и на повтор отвечать `{"ok":true, "call_id": <тот
|
||||
@@ -174,52 +181,96 @@ final class ObzvonClient
|
||||
*
|
||||
* 🪤 Телефон входит в знак только хешем: знак ложится в журналы и уедет к
|
||||
* роботу, а телефон — персональные данные человека.
|
||||
*
|
||||
* 🔴 Номер приводится к одному виду {@see self::odinVidNomera()} — тем же
|
||||
* самым, каким он поедет роботу в теле задания. Это одно место, а не два.
|
||||
*/
|
||||
public static function klyuchZadaniya(int $tenantId, int $campaignId, string $telefon, int $popytkaNo): string
|
||||
{
|
||||
return 'z'.substr(hash('sha256', implode('|', [
|
||||
$tenantId,
|
||||
$campaignId,
|
||||
preg_replace('/\D+/', '', $telefon) ?? '',
|
||||
self::odinVidNomera($telefon),
|
||||
$popytkaNo,
|
||||
])), 0, 40);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 ОДИН ВИД НОМЕРА — единственное место во всём клиенте, где номер
|
||||
* приводится к канону, и оба потребителя берут его отсюда: и знак
|
||||
* ({@see self::klyuchZadaniya()}), и тело задания ({@see self::pozvonit()}).
|
||||
*
|
||||
* Пока мест было два, один и тот же человек, записанный `+7 999 000-00-01`
|
||||
* и `79990000001`, давал ОДИН знак и РАЗНЫЕ тела. Приёмник робота (З-0.6)
|
||||
* снимает отпечаток с тела ДОСЛОВНО и на такую пару отвечает 422 — то есть
|
||||
* честный повтор получал отказ вместо звонка.
|
||||
*
|
||||
* 🪤 Что этот канон НЕ делает, говорю вслух: он снимает ЗАПИСЬ (плюсы,
|
||||
* скобки, пробелы, тире), но не решает, один ли человек за `8999…` и
|
||||
* `+7999…`. Для портала это сегодня РАЗНЫЕ люди — разный знак, два звонка.
|
||||
* Считать ли их одним, решает владелец, и это отдельное решение: ошибись оно
|
||||
* в другую сторону — два РАЗНЫХ человека слились бы в один знак, и второму
|
||||
* не позвонили бы никогда. Молча угадывать такое нельзя.
|
||||
*/
|
||||
private static function odinVidNomera(string $telefon): string
|
||||
{
|
||||
return preg_replace('/\D+/', '', $telefon) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Попросить робота позвонить по номеру.
|
||||
*
|
||||
* 🔴 Один вызов = РОВНО ОДИН запрос к роботу. Сам не повторяет — см. шапку.
|
||||
*
|
||||
* @param string $klyuchZadaniya знак от {@see self::klyuchZadaniya()}; пустой = не звоним
|
||||
* 🔴 ЗНАК СЧИТАЕТСЯ ЗДЕСЬ, А НЕ ПРИНИМАЕТСЯ СНАРУЖИ. Раньше знак и телефон
|
||||
* приходили двумя независимыми доводами, и ничто не заставляло их сойтись:
|
||||
* работник очереди, посчитавший знак один раз и перебирающий номера, отправил
|
||||
* бы роботу рассогласованную пару — робот ответил бы 422, а человеку не
|
||||
* позвонили бы МОЛЧА. Теперь рассогласовать нечего: наружу принимается ровно
|
||||
* то, ИЗ ЧЕГО знак складывается, и складывает его сам клиент.
|
||||
*
|
||||
* 🪤 Что отсюда следует для правила счёта попыток (решение владельца Р124).
|
||||
* Робот помнит знаки сутки и различает попытки по `$popytkaNo`. Значит на
|
||||
* ПОВТОРЕ после исхода {@see ObzvonOtvet::zvonokMogSostoyatsya()} = «да»
|
||||
* (робот молчит, 5xx, ответ без условленного слова) номер попытки обязан
|
||||
* остаться ТЕМ ЖЕ — иначе знак станет другим и защита не сработает именно
|
||||
* в том случае, ради которого заведена. Прибавлять попытку можно только
|
||||
* после исхода, где звонка ТОЧНО не было (дверь закрыта, занято, отказ),
|
||||
* либо после состоявшегося и завершённого разговора. Сам клиент этого
|
||||
* правила не знает и знать не должен — он считает знак от того, что ему
|
||||
* сказали. Довод виден в каждом вызове нарочно: правило будет исполняться
|
||||
* на глазах, а не прятаться внутри.
|
||||
*
|
||||
* @param array<string, mixed> $dopolnitelno что ещё сказать роботу (сценарий, номер «от кого»)
|
||||
*
|
||||
* 🪤 `$dopolnitelno` тоже входит в отпечаток тела у робота. Значит оно
|
||||
* обязано быть ОДИНАКОВЫМ у повтора той же попытки: время «сейчас», случайное
|
||||
* число или поле, добавленное выкатом между попыткой и повтором, дадут
|
||||
* честному повтору 422. Порядок ключей роли не играет — приёмник сортирует.
|
||||
*/
|
||||
public function pozvonit(string $klyuchZadaniya, string $telefon, array $dopolnitelno = []): ObzvonOtvet
|
||||
public function pozvonit(int $tenantId, int $campaignId, string $telefon, int $popytkaNo, array $dopolnitelno = []): ObzvonOtvet
|
||||
{
|
||||
$klyuchZadaniya = trim($klyuchZadaniya);
|
||||
$telefon = trim($telefon);
|
||||
// 🔴 Один и тот же канон и для знака, и для тела: роботу уезжает ровно
|
||||
// тот номер, из которого посчитан знак. Другого номера здесь просто нет.
|
||||
$nomer = self::odinVidNomera($telefon);
|
||||
|
||||
if ($klyuchZadaniya === '') {
|
||||
// Не «на всякий случай», а по существу: без знака робот не отличит
|
||||
// повтор от нового задания, и первая же потеря ответа обернётся
|
||||
// вторым звонком человеку. Молчать об этом нельзя — это ошибка кода.
|
||||
Log::error('обзвон: задание без опознавательного знака — звонить отказано', [
|
||||
'ruchka' => 'pozvonit',
|
||||
]);
|
||||
|
||||
return ObzvonOtvet::dverZakryta('задание без опознавательного знака');
|
||||
}
|
||||
|
||||
if ($telefon === '') {
|
||||
if ($nomer === '') {
|
||||
// Сюда попадает и пустая строка, и строка вовсе без цифр («абв»).
|
||||
// Второе важно отдельно: раньше такие строки давали ОДИН знак на
|
||||
// всех (цифр ноль — хешу нечего различать), и два разных задания
|
||||
// столкнулись бы знаками у робота.
|
||||
Log::error('обзвон: задание без номера — звонить отказано', [
|
||||
'zadanie' => $klyuchZadaniya,
|
||||
'ruchka' => 'pozvonit',
|
||||
]);
|
||||
|
||||
return ObzvonOtvet::dverZakryta('задание без номера');
|
||||
}
|
||||
|
||||
return $this->obratitsya('pozvonit', $klyuchZadaniya, array_merge($dopolnitelno, [
|
||||
'zadanie' => $klyuchZadaniya,
|
||||
'telefon' => $telefon,
|
||||
$znak = self::klyuchZadaniya($tenantId, $campaignId, $nomer, $popytkaNo);
|
||||
|
||||
return $this->obratitsya('pozvonit', $znak, array_merge($dopolnitelno, [
|
||||
'zadanie' => $znak,
|
||||
'telefon' => $nomer,
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@ use Illuminate\Support\Facades\Log;
|
||||
*
|
||||
* 🔴 Чего эти сторожа НЕ доказывают, говорю сразу и вслух:
|
||||
*
|
||||
* 1. **Что робот и вправду узнаёт повтор по знаку задания.** Приёмника на машине
|
||||
* робота ещё нет вовсе (З-0.6). Здесь доказано только то, что портал знак
|
||||
* вырабатывает устойчиво, шлёт его в двух заголовках и без знака звонить
|
||||
* отказывается. Защита от двойного звонка ЗАЯВЛЕНА, но не замкнута.
|
||||
* 1. **Что робот и вправду узнаёт повтор по знаку задания.** Здесь доказано
|
||||
* только то, что портал знак вырабатывает устойчиво, складывает его САМ (а
|
||||
* значит рассогласовать его с номером нечем), шлёт одно и то же и в теле, и в
|
||||
* двух заголовках. Замыкает защиту приёмник З-0.6, и он не здесь.
|
||||
* 2. **Что предел времени и вправду обрывает медленное чтение.** Подставной робот
|
||||
* подменяет собой весь перевозчик Guzzle — настоящие пределы времени при
|
||||
* подставном роботе не срабатывают в принципе. Здесь доказано поведение НА
|
||||
@@ -49,11 +49,44 @@ function klientObzvona(array $peremeny = []): ObzvonClient
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Одно пробное задание — ровно то, ИЗ ЧЕГО клиент сам сложит знак.
|
||||
*
|
||||
* 🔴 Знака среди доводов нет и быть не может: `pozvonit` его не принимает.
|
||||
* Раньше сторожа звали `pozvonit(znakZadaniya(), '+79991234567')` — то есть
|
||||
* знак был посчитан от одного написания номера, а в тело ехало другое. Ровно
|
||||
* та рассогласованная пара, ради которой всё это переделано, жила прямо здесь.
|
||||
*/
|
||||
function zadanieProbnoe(array $peremeny = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'tenantId' => 1,
|
||||
'campaignId' => 7,
|
||||
'telefon' => '+7 999 123-45-67',
|
||||
'popytkaNo' => 1,
|
||||
], $peremeny);
|
||||
}
|
||||
|
||||
/** Тот знак, какой клиент обязан сложить сам для {@see zadanieProbnoe()}. */
|
||||
function znakZadaniya(): string
|
||||
{
|
||||
return ObzvonClient::klyuchZadaniya(1, 7, '+7 999 123-45-67', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Тела ВСЕХ заданий, ушедших роботу на ручку `pozvonit`, по порядку.
|
||||
*
|
||||
* 🪤 Сторожа ниже смотрят на то, что и вправду уехало в сеть, а не на текст
|
||||
* исходника: два помощника этой смены уже платили за сторожа, находившего своё
|
||||
* же пояснение в файле и зеленевшего при сломанном коде.
|
||||
*/
|
||||
function telaUshedshih(): array
|
||||
{
|
||||
return Http::recorded(fn (Request $z) => str_ends_with($z->url(), '/pozvonit'))
|
||||
->map(fn (array $para) => $para[0]->data())
|
||||
->all();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Проверка 1 плана: робот молчит — портал не падает
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -61,7 +94,7 @@ function znakZadaniya(): string
|
||||
it('робот молчит — портал не падает, а говорит «не знаю»', function () {
|
||||
Http::fake(fn () => throw new ConnectionException('cURL error 7: Failed to connect'));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno)
|
||||
->and($otvet->poyasnenie)->toContain('робот молчит');
|
||||
@@ -70,7 +103,7 @@ it('робот молчит — портал не падает, а говори
|
||||
it('🔴 молчание робота НЕ значит «звонка не было» — повторять вслепую нельзя', function () {
|
||||
Http::fake(fn () => throw new ConnectionException('cURL error 7: Failed to connect'));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
// Ровно тот случай, ради которого исходов пять: робот мог принять задание и
|
||||
// уже звонить, а ответ потерялся по дороге. Скажи клиент «не вышло» — живому
|
||||
@@ -90,7 +123,7 @@ it('🔴 клиент НЕ повторяет запрос сам — на мо
|
||||
throw new ConnectionException('оборвано');
|
||||
});
|
||||
|
||||
klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
|
||||
klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($skolko)->toBe(1);
|
||||
});
|
||||
@@ -99,7 +132,7 @@ it('о молчании робота пишется строка уровня «
|
||||
$zhurnal = Log::spy();
|
||||
Http::fake(fn () => throw new ConnectionException('оборвано'));
|
||||
|
||||
klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
|
||||
klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
// 🔴 На бою LOG_LEVEL=warning: всё ниже предупреждения не доезжает НИКОГДА
|
||||
// (оплачено 04.08.2026 — починка выглядела сделанной и была мёртвой).
|
||||
@@ -117,7 +150,7 @@ it('выход за предел времени — «не знаю», рабо
|
||||
// Именно так выглядит выход за предел времени в Guzzle: ConnectionException.
|
||||
Http::fake(fn () => throw new ConnectionException('cURL error 28: Operation timed out after 15001 ms'));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno)
|
||||
->and($otvet->poyasnenie)->toContain('timed out');
|
||||
@@ -142,7 +175,7 @@ it('🔴 пределов времени ДВА, и оба не нулевые',
|
||||
it('пустой адрес — наружу не ходим, звонка точно не было', function () {
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona(['endpoint' => ''])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
$otvet = klientObzvona(['endpoint' => ''])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
|
||||
@@ -152,7 +185,7 @@ it('пустой адрес — наружу не ходим, звонка то
|
||||
it('пустой ключ — наружу не ходим', function () {
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona(['key' => ''])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
$otvet = klientObzvona(['key' => ''])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
|
||||
->and($otvet->poyasnenie)->toContain('тайный ключ');
|
||||
@@ -163,7 +196,7 @@ it('закрытая дверь не сыплет исключениями и н
|
||||
Http::fake();
|
||||
$klient = klientObzvona(['endpoint' => '']);
|
||||
|
||||
expect($klient->pozvonit(znakZadaniya(), '+79991234567')->poluchenLi())->toBeFalse()
|
||||
expect($klient->pozvonit(...zadanieProbnoe())->poluchenLi())->toBeFalse()
|
||||
->and($klient->sostoyanie('c-1')->poluchenLi())->toBeFalse()
|
||||
->and($klient->ostanovit('c-1')->poluchenLi())->toBeFalse();
|
||||
});
|
||||
@@ -176,7 +209,7 @@ it('🔴 ответ 200 и мусором — это НЕ успех', function
|
||||
// Тот самый урок: «ответ меньше 500 = жив» физически не мог покраснеть.
|
||||
Http::fake(fn () => Http::response(['ерунда' => 'какая-то'], 200));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->poluchenLi())->toBeFalse();
|
||||
});
|
||||
@@ -186,7 +219,7 @@ it('🔴 ответ 200 и мусором — это и НЕ отказ, а «н
|
||||
// Записать мусор в «отказ» значит вернуть дыру с двойным звонком.
|
||||
Http::fake(fn () => Http::response('совсем не json', 200));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeTrue();
|
||||
@@ -195,7 +228,7 @@ it('🔴 ответ 200 и мусором — это и НЕ отказ, а «н
|
||||
it('робот сказал «нет» — это отказ, и звонка точно не было', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => false, 'reason' => 'кампания не найдена'], 200));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Otkaz)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse()
|
||||
@@ -205,7 +238,7 @@ it('робот сказал «нет» — это отказ, и звонка т
|
||||
it('робот сломался 5xx — «не знаю», а не «не было»', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => false], 500));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeTrue();
|
||||
@@ -214,7 +247,7 @@ it('робот сломался 5xx — «не знаю», а не «не был
|
||||
it('робот ответил 4xx — понял и отказался, звонка не было', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => false], 422));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Otkaz)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
|
||||
@@ -223,7 +256,7 @@ it('робот ответил 4xx — понял и отказался, звон
|
||||
it('робот сказал условленное слово — задание принято, внешний номер запомнен', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'rob-42'], 200));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen)
|
||||
->and($otvet->vneshniyIdZvonka)->toBe('rob-42');
|
||||
@@ -254,22 +287,11 @@ it('🔴 в знаке задания нет телефона человека
|
||||
expect(ObzvonClient::klyuchZadaniya(1, 7, '+79991234567', 1))->not->toContain('9991234567');
|
||||
});
|
||||
|
||||
it('🔴 задание без знака — не звоним вовсе, и это ошибка в журнале', function () {
|
||||
$zhurnal = Log::spy();
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona()->pozvonit('', '+79991234567');
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
$zhurnal->shouldHaveReceived('error');
|
||||
});
|
||||
|
||||
it('знак задания уходит роботу двумя заголовками, а ключ — своим', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
$znak = znakZadaniya();
|
||||
|
||||
klientObzvona()->pozvonit($znak, '+79991234567');
|
||||
klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
Http::assertSent(fn (Request $z) => $z->hasHeader('X-Obzvon-Zadanie', $znak)
|
||||
&& $z->hasHeader('Idempotency-Key', $znak)
|
||||
@@ -279,12 +301,185 @@ it('знак задания уходит роботу двумя заголов
|
||||
it('задание без номера — не звоним', function () {
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(znakZadaniya(), ' ');
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => ' ']));
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('🔴 номер без единой цифры — не звоним, и это ошибка в журнале', function () {
|
||||
// 🪤 Раньше сюда проваливалось всё, где нет цифр: `trim` такую строку не
|
||||
// ловил, а знак от неё получался ОДИН И ТОТ ЖЕ у любых таких заданий —
|
||||
// цифр ноль, хешу нечего различать. Два разных задания столкнулись бы
|
||||
// знаками у робота, и одному из людей не позвонили бы вовсе.
|
||||
$zhurnal = Log::spy();
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => 'абв']));
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
|
||||
Http::assertNothingSent();
|
||||
$zhurnal->shouldHaveReceived('error');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 🔴🔴 ЗНАК И ТЕЛО ЗАДАНИЯ БОЛЬШЕ НЕ МОГУТ РАЗОЙТИСЬ
|
||||
//
|
||||
// Что было замерено до починки: знак считался от ОДНИХ ЦИФР телефона, а в тело
|
||||
// задания клался номер КАК ЕСТЬ. Один и тот же человек, записанный
|
||||
// `+7 999 000-00-01` и `79990000001`, давал ОДИН знак и РАЗНЫЕ тела. Приёмник
|
||||
// робота (З-0.6) снимает отпечаток с тела дословно и на такую пару отвечает 422
|
||||
// — честный повтор получал отказ вместо звонка.
|
||||
//
|
||||
// И вторая половина той же беды: знак приходил ОТДЕЛЬНЫМ доводом от телефона.
|
||||
// Работник очереди, посчитавший знак один раз и перебирающий номера, отправил
|
||||
// бы роботу рассогласованную пару, и человеку не позвонили бы МОЛЧА.
|
||||
//
|
||||
// 🪤 Сторожа ниже смотрят на то, что ушло в сеть подставному роботу, — на
|
||||
// работу, а не на слова в исходнике.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('🔴 А (контроль): одно задание дважды подряд — знак совпал и тело совпало', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
$klient = klientObzvona();
|
||||
$klient->pozvonit(...zadanieProbnoe());
|
||||
$klient->pozvonit(...zadanieProbnoe());
|
||||
|
||||
$tela = telaUshedshih();
|
||||
|
||||
expect($tela)->toHaveCount(2)
|
||||
->and($tela[1]['zadanie'])->toBe($tela[0]['zadanie'])
|
||||
->and($tela[1]['telefon'])->toBe($tela[0]['telefon']);
|
||||
});
|
||||
|
||||
it('🔴 Б: тот же человек, записанный двумя видами — и знак, и ТЕЛО совпадают', function () {
|
||||
// 🔴 Тот самый нож. До починки совпадал только знак: тело ехало «как есть»,
|
||||
// и робот видел два разных задания под одним знаком → 422 честному повтору.
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
$klient = klientObzvona();
|
||||
$klient->pozvonit(...zadanieProbnoe(['telefon' => '+7 999 000-00-01']));
|
||||
$klient->pozvonit(...zadanieProbnoe(['telefon' => '79990000001']));
|
||||
|
||||
$tela = telaUshedshih();
|
||||
|
||||
expect($tela)->toHaveCount(2)
|
||||
->and($tela[1]['zadanie'])->toBe($tela[0]['zadanie'])
|
||||
->and($tela[1]['telefon'])->toBe($tela[0]['telefon']);
|
||||
});
|
||||
|
||||
it('🔴 В: рассогласовать знак и телефон нечем — знак снаружи не задаётся вовсе', function () {
|
||||
// Проверяем не текст файла, а САМ ДОВОДНЫЙ ряд метода: если кто-нибудь
|
||||
// вернёт знак отдельным доводом, сторож покраснеет.
|
||||
$dovody = array_map(
|
||||
fn (ReflectionParameter $p) => $p->getName(),
|
||||
(new ReflectionMethod(ObzvonClient::class, 'pozvonit'))->getParameters(),
|
||||
);
|
||||
|
||||
expect($dovody)->toBe(['tenantId', 'campaignId', 'telefon', 'popytkaNo', 'dopolnitelno']);
|
||||
});
|
||||
|
||||
it('🔴 В: чем клиента ни зови — знак в теле всегда сходится с телефоном в теле', function () {
|
||||
// Главный сторож починки. Он проверяет САМО СВОЙСТВО, ради которого всё
|
||||
// сделано: для КАЖДОГО ушедшего задания знак обязан быть тем самым, какой
|
||||
// считается от номера, уехавшего в этом же теле. Разойдись они хоть на один
|
||||
// знак — робот ответит 422, и человеку не позвонят.
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
$vsyakie = [
|
||||
['telefon' => '+7 999 000-00-01'],
|
||||
['telefon' => '79990000001'],
|
||||
['telefon' => ' 8 (999) 000 00 01 '],
|
||||
['tenantId' => 42, 'telefon' => '+7-999-000-00-01'],
|
||||
['campaignId' => 99, 'telefon' => '+7 999 000 00 01'],
|
||||
['popytkaNo' => 3, 'telefon' => '+7 999 000-00-01'],
|
||||
];
|
||||
|
||||
$klient = klientObzvona();
|
||||
|
||||
foreach ($vsyakie as $peremeny) {
|
||||
$klient->pozvonit(...zadanieProbnoe($peremeny));
|
||||
}
|
||||
|
||||
$tela = telaUshedshih();
|
||||
|
||||
expect($tela)->toHaveCount(count($vsyakie));
|
||||
|
||||
foreach ($tela as $nomer => $telo) {
|
||||
$zadanie = zadanieProbnoe($vsyakie[$nomer]);
|
||||
|
||||
// 🪤 Первая половина обязательна отдельно, и вот почему. `klyuchZadaniya`
|
||||
// сама приводит номер к одному виду — значит одной этой сверки МАЛО:
|
||||
// положи клиент в тело номер с плюсами и скобками, знак всё равно сошёлся
|
||||
// бы, а робот сравнивает тело ДОСЛОВНО и ответил бы 422. Замерено: без
|
||||
// этой строки сторож оставался зелёным при возвращённой беде.
|
||||
expect($telo['telefon'])->toMatch('/^\d+$/');
|
||||
|
||||
expect($telo['zadanie'])->toBe(ObzvonClient::klyuchZadaniya(
|
||||
$zadanie['tenantId'],
|
||||
$zadanie['campaignId'],
|
||||
$telo['telefon'], // 🔴 именно тот номер, что уехал роботу
|
||||
$zadanie['popytkaNo'],
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
it('🔴 Г: вторая попытка по тому же номеру — знак ДРУГОЙ, звонок законный', function () {
|
||||
// Вторая попытка дозвона — законный второй звонок, глушить его нельзя.
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
$klient = klientObzvona();
|
||||
$klient->pozvonit(...zadanieProbnoe(['popytkaNo' => 1]));
|
||||
$otvet = $klient->pozvonit(...zadanieProbnoe(['popytkaNo' => 2]));
|
||||
|
||||
$tela = telaUshedshih();
|
||||
|
||||
expect($tela)->toHaveCount(2)
|
||||
->and($tela[1]['zadanie'])->not->toBe($tela[0]['zadanie'])
|
||||
->and($tela[1]['telefon'])->toBe($tela[0]['telefon'])
|
||||
->and($otvet->poluchenLi())->toBeTrue();
|
||||
});
|
||||
|
||||
it('🔴 знак в заголовках — тот же самый, что в теле', function () {
|
||||
// Приёмник сверяет тело с заголовком и на расхождении отвечает 400. Разойдись
|
||||
// они здесь — не дозвонились бы вовсе, и по коду 400 причину не узнать.
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => '+7 999 000-00-01']));
|
||||
|
||||
$telo = telaUshedshih()[0];
|
||||
|
||||
Http::assertSent(fn (Request $z) => $z->hasHeader('X-Obzvon-Zadanie', $telo['zadanie'])
|
||||
&& $z->hasHeader('Idempotency-Key', $telo['zadanie']));
|
||||
});
|
||||
|
||||
it('🔴 в теле задания номер без украшений — того же вида, из какого посчитан знак', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => '+7 (999) 000-00-01']));
|
||||
|
||||
expect(telaUshedshih()[0]['telefon'])->toBe('79990000001');
|
||||
});
|
||||
|
||||
it('🔴 «что ещё сказать роботу» не может перебить знак или номер', function () {
|
||||
// Отпечаток тела у робота снимается со ВСЕГО тела. Дай мы `dopolnitelno`
|
||||
// перебить `zadanie` или `telefon` — рассогласование вернулось бы с другого
|
||||
// конца, уже никем не охраняемое.
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
klientObzvona()->pozvonit(...zadanieProbnoe([
|
||||
'dopolnitelno' => ['zadanie' => 'z-chuzhoy', 'telefon' => '+70000000000', 'skript' => 'privet'],
|
||||
]));
|
||||
|
||||
$telo = telaUshedshih()[0];
|
||||
|
||||
expect($telo['zadanie'])->toBe(znakZadaniya())
|
||||
->and($telo['telefon'])->toBe('79991234567')
|
||||
->and($telo['skript'])->toBe('privet');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 🔴 Адрес с опечаткой уводит тайный ключ чужому
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -293,7 +488,7 @@ it('🔴 адрес ведёт на неразрешённый хост — кл
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona(['endpoint' => 'https://rrobot.test/obzvon'])
|
||||
->pozvonit(znakZadaniya(), '+79991234567');
|
||||
->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
|
||||
->and($otvet->poyasnenie)->toContain('неразрешённый хост');
|
||||
@@ -303,7 +498,7 @@ it('🔴 адрес ведёт на неразрешённый хост — кл
|
||||
it('🔴 пустой список разрешённых хостов закрывает всё, а не открывает', function () {
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona(['razreshyonnyeHosty' => []])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
$otvet = klientObzvona(['razreshyonnyeHosty' => []])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
@@ -312,7 +507,7 @@ it('🔴 пустой список разрешённых хостов закр
|
||||
it('адрес без хоста вовсе — дверь закрыта', function () {
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona(['endpoint' => 'просто-строка'])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
$otvet = klientObzvona(['endpoint' => 'просто-строка'])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
@@ -335,7 +530,7 @@ it('🔴 ж: разрешённый хост, но http без шифра — к
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://51.250.1.97/lena',
|
||||
'razreshyonnyeHosty' => ['51.250.1.97'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse()
|
||||
@@ -351,7 +546,7 @@ it('🔴 з: тот же хост по https — работает и после
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'https://51.250.1.97/lena',
|
||||
'razreshyonnyeHosty' => ['51.250.1.97'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen)
|
||||
->and($otvet->vneshniyIdZvonka)->toBe('rob-7');
|
||||
@@ -364,7 +559,7 @@ it('🔴 и: свой компьютер, http на localhost — работае
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://localhost:8080/lena',
|
||||
'razreshyonnyeHosty' => ['localhost'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen);
|
||||
});
|
||||
@@ -376,7 +571,7 @@ it('🔴 и: свой компьютер, http на 127.0.0.1 — работае
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://127.0.0.1:8080/lena',
|
||||
'razreshyonnyeHosty' => ['127.0.0.1'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen);
|
||||
});
|
||||
@@ -388,7 +583,7 @@ it('🔴 к: тот же местный адрес НА БОЕВОМ — две
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://127.0.0.1:8080/lena',
|
||||
'razreshyonnyeHosty' => ['127.0.0.1'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
|
||||
@@ -414,7 +609,7 @@ it('🔴 APP_ENV=local на боевой машине НЕ открывает ht
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://51.250.1.97/lena',
|
||||
'razreshyonnyeHosty' => ['51.250.1.97'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
@@ -429,7 +624,7 @@ it('🔴 «своя машина» — не список слов: похоже
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://localhost.zloumyshlennik.ru/lena',
|
||||
'razreshyonnyeHosty' => ['localhost.zloumyshlennik.ru'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
@@ -444,7 +639,7 @@ it('🔴 вся петля 127.0.0.0/8 — своя машина, а сосед
|
||||
expect(klientObzvona([
|
||||
'endpoint' => 'http://127.0.1.1:8080/lena',
|
||||
'razreshyonnyeHosty' => ['127.0.1.1'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567')->poluchenLi())->toBeTrue();
|
||||
])->pozvonit(...zadanieProbnoe())->poluchenLi())->toBeTrue();
|
||||
});
|
||||
|
||||
it('🔴 адрес, похожий на петлю, но ею не являющийся — дверь закрыта', function () {
|
||||
@@ -455,7 +650,7 @@ it('🔴 адрес, похожий на петлю, но ею не являющ
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://10.0.127.1:8080/lena',
|
||||
'razreshyonnyeHosty' => ['10.0.127.1'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
@@ -469,7 +664,7 @@ it('🔴 запрет http на боевом виден в журнале, и у
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://51.250.1.97/lena',
|
||||
'razreshyonnyeHosty' => ['51.250.1.97'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
// 🪤 Сперва — что дверь закрыта ИМЕННО из-за шифрования, а уж потом про
|
||||
// журнал. Без первой строки сторож зеленел бы от чужого предупреждения в
|
||||
@@ -522,7 +717,7 @@ it('🔴 все места заняты — исход «занято», нар
|
||||
$chuzhie[] = $zamok;
|
||||
}
|
||||
|
||||
$otvet = klientObzvona(['concurrency' => 2])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
$otvet = klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Zanyato)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
|
||||
@@ -545,7 +740,7 @@ it('освободилось место — задание проходит', fu
|
||||
|
||||
$zanyatye[1]->release();
|
||||
|
||||
expect(klientObzvona(['concurrency' => 2])->pozvonit(znakZadaniya(), '+79991234567')->poluchenLi())
|
||||
expect(klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe())->poluchenLi())
|
||||
->toBeTrue();
|
||||
|
||||
$zanyatye[0]->release();
|
||||
@@ -559,7 +754,7 @@ it('🔴 лишнее задание ЖДЁТ свободного места,
|
||||
|
||||
$nachalo = microtime(true);
|
||||
$otvet = klientObzvona(['concurrency' => 1, 'waitSeconds' => 1])
|
||||
->pozvonit(znakZadaniya(), '+79991234567');
|
||||
->pozvonit(...zadanieProbnoe());
|
||||
$proshlo = microtime(true) - $nachalo;
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Zanyato)
|
||||
@@ -571,7 +766,7 @@ it('🔴 лишнее задание ЖДЁТ свободного места,
|
||||
it('место возвращается после успешного обращения', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
klientObzvona(['concurrency' => 2])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
foreach ([0, 1] as $nomer) {
|
||||
$zamok = Cache::lock('obzvon:mesto:'.$nomer, 60);
|
||||
@@ -584,7 +779,7 @@ it('🔴 место возвращается, даже когда робот м
|
||||
// Иначе одна беда навсегда съедала бы место, и обзвон затухал бы по одному.
|
||||
Http::fake(fn () => throw new ConnectionException('оборвано'));
|
||||
|
||||
klientObzvona(['concurrency' => 2])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
foreach ([0, 1] as $nomer) {
|
||||
$zamok = Cache::lock('obzvon:mesto:'.$nomer, 60);
|
||||
@@ -601,7 +796,7 @@ it('три умения зовут три разные ручки робота',
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
$klient = klientObzvona();
|
||||
|
||||
$klient->pozvonit(znakZadaniya(), '+79991234567');
|
||||
$klient->pozvonit(...zadanieProbnoe());
|
||||
$klient->sostoyanie('rob-42');
|
||||
$klient->ostanovit('rob-42', 'человек попросил');
|
||||
|
||||
@@ -647,7 +842,7 @@ it('клиент из настроек берёт адрес, ключ и пре
|
||||
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
expect(ObzvonClient::izNastroek()->pozvonit(znakZadaniya(), '+79991234567')->poluchenLi())->toBeTrue();
|
||||
expect(ObzvonClient::izNastroek()->pozvonit(...zadanieProbnoe())->poluchenLi())->toBeTrue();
|
||||
|
||||
Http::assertSent(fn (Request $z) => $z->hasHeader('X-Obzvon-Key', 'iz-nastroek'));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
# Отчёт помощника — знак задания и тело задания больше не могут разойтись
|
||||
|
||||
**Дата:** 07.08.2026
|
||||
**Ветка:** `feat/prospects-manual-testing-kp`
|
||||
**Мои файлы:** `app/app/Services/Obzvon/ObzvonClient.php`,
|
||||
`app/tests/Feature/Obzvon/ObzvonClientShovTest.php`
|
||||
|
||||
---
|
||||
|
||||
## 0. Моя ошибка первой
|
||||
|
||||
**Мой главный сторож был слеп ровно к той половине беды, ради которой я его
|
||||
писал, и я узнал об этом только потому, что и вправду сломал починку.**
|
||||
|
||||
Я написал сторожа «чем клиента ни зови — знак в теле всегда сходится с телефоном
|
||||
в теле»: он берёт ушедшее роботу тело и проверяет, что
|
||||
`zadanie === klyuchZadaniya(арендатор, кампания, telefon_из_тела, попытка)`.
|
||||
Выглядело как главная проверка всей работы. Потом я вернул беду обратно (положил
|
||||
в тело номер «как есть») — и **этот сторож остался ЗЕЛЁНЫМ**. Причина простая и
|
||||
обидная: `klyuchZadaniya` сама приводит номер к одному виду, поэтому от
|
||||
`+7 999 000-00-01` она даёт тот же знак, что и от `79990000001`. Сторож сверял
|
||||
согласие «с точностью до нормализации», а робот сравнивает тело **дословно**.
|
||||
|
||||
Починил: добавил в того же сторожа отдельную строку `expect($telo['telefon'])
|
||||
->toMatch('/^\d+$/')` — тело обязано нести уже приведённый номер. После этого при
|
||||
той же самой поломке сторож покраснел (замер — §3, поломка №1, было 3 красных,
|
||||
стало 4). Если бы я не ломал починку руками, а поверил зелёному, я бы сдал работу
|
||||
с дырявым главным сторожем.
|
||||
|
||||
---
|
||||
|
||||
## 1. Разбор задания
|
||||
|
||||
### 1.1. Картина беды владельца — подтверждаю обе половины, замерено
|
||||
|
||||
1. **Знак и телефон приходили двумя независимыми доводами.** Так и было:
|
||||
`pozvonit(string $klyuchZadaniya, string $telefon, ...)`. Ничто их не
|
||||
связывало.
|
||||
2. **Знак считался от одних цифр, а в тело клался номер как есть.** Так и было:
|
||||
`klyuchZadaniya` — `preg_replace('/\D+/', '', $telefon)`, `pozvonit` — просто
|
||||
`trim($telefon)`.
|
||||
|
||||
Причём **рассогласованная пара жила прямо в сторожах**: почти все они звали
|
||||
`pozvonit(znakZadaniya(), '+79991234567')`, где `znakZadaniya()` считался от
|
||||
`'+7 999 123-45-67'`. То есть знак от одного написания, тело от другого. Сегодня
|
||||
это никого не роняло только потому, что приёмника рядом нет.
|
||||
|
||||
### 1.2. Что я проверил в приёмнике (читал, не правил)
|
||||
|
||||
Читал `bots/lena-golos/priyomnik.py`. Важное для этой работы:
|
||||
|
||||
- отпечаток тела снимается **вычитанием**: в него входит всё, кроме `zadanie`
|
||||
(`POLYA_VNE_OTPECHATKA`) — значит и `telefon`, и всё `dopolnitelno`;
|
||||
- **порядок ключей роли не играет** — приёмник сортирует
|
||||
(`json.dumps(..., sort_keys=True)`). Значит порядок ключей на портале
|
||||
выравнивать не надо, и я этого не делал;
|
||||
- на расхождение тела — **422**, то есть 4xx, то есть для портала «звонка не
|
||||
было». Приёмник сам говорит вслух: «правильное место починки — портал».
|
||||
|
||||
Вывод: портал обязан гарантировать, что **у повтора той же попытки тело
|
||||
побайтово то же**. Одного согласия «знак сходится» мало.
|
||||
|
||||
### 1.3. 🔴 Второе место того же класса, о котором в задании не сказано
|
||||
|
||||
**Строка вовсе без цифр давала один и тот же знак на все такие задания.**
|
||||
`trim` ловил `' '`, но не ловил `'абв'`, `'---'` или `'+()'`: они проходили
|
||||
дальше, а `klyuchZadaniya` от них получала **пустую строку цифр** — то есть знак
|
||||
зависел только от арендатора, кампании и попытки. Два разных задания с
|
||||
разными «мусорными» номерами **столкнулись бы знаками у робота**, и второму
|
||||
человеку не позвонили бы вовсе (робот ответил бы «повтор» либо 422).
|
||||
|
||||
Закрыл заодно: теперь отказ идёт по признаку «в номере ноль цифр», а не по
|
||||
`trim`. Сторож — «🔴 номер без единой цифры — не звоним, и это ошибка в журнале».
|
||||
|
||||
### 1.4. 🔴 Третье место того же класса — НЕ закрывал, потому что это не моё решение
|
||||
|
||||
`8 999 000-00-01` и `+7 999 000-00-01` — почти наверняка **один и тот же
|
||||
человек**, но цифры у них разные (`89990000001` против `79990000001`), значит
|
||||
знак разный, значит **два звонка одному человеку**. Это ровно та беда, что в
|
||||
задании, только с другого конца.
|
||||
|
||||
Я это **не чиню намеренно** и говорю почему. Правило «какие записи номера значат
|
||||
одного человека» — решение владельца, а не помощника:
|
||||
|
||||
- ошибись оно в одну сторону — человеку позвонят дважды (сегодняшнее состояние);
|
||||
- ошибись в другую — **два разных человека сольются в один знак**, и второму не
|
||||
позвонят НИКОГДА. Второе хуже и тише.
|
||||
|
||||
Кроме того, `8` — приставка не всемирная: слепое `8 → 7` сломает любой не-российский
|
||||
номер. Написал вслух в §7 как открытый вопрос владельцу.
|
||||
|
||||
### 1.5. 🔴 Четвёртое место — `dopolnitelno` входит в отпечаток тела
|
||||
|
||||
`dopolnitelno` (сценарий, номер «от кого», имя собеседника) уезжает в том же
|
||||
теле и **целиком входит в отпечаток робота**. Значит у повтора той же попытки
|
||||
оно обязано быть **тем же**: время «сейчас», случайное число или поле,
|
||||
добавленное выкатом между попыткой и повтором, дадут честному повтору 422.
|
||||
|
||||
Чинить это в клиенте нечем — содержимое даёт зовущий. Что сделал: написал это
|
||||
🪤-предупреждением прямо в докблоке `pozvonit`, чтобы будущий работник очереди
|
||||
прочёл его в момент, когда пишет вызов. Порядок ключей выравнивать не надо —
|
||||
приёмник сортирует (§1.2).
|
||||
|
||||
### 1.6. Проверка владельца: «не сломает ли смена подписи `pozvonit`?»
|
||||
|
||||
**Не ошиблись — боевых зовущих ноль.** Подробный замер поимённо — §6.
|
||||
|
||||
---
|
||||
|
||||
## 2. Какой путь выбран и почему
|
||||
|
||||
**Выбран путь первый: портал считает знак САМ внутри `pozvonit`, а наружу
|
||||
принимает то, из чего знак складывается.**
|
||||
|
||||
Новая подпись:
|
||||
|
||||
```php
|
||||
public function pozvonit(
|
||||
int $tenantId, int $campaignId, string $telefon, int $popytkaNo,
|
||||
array $dopolnitelno = [],
|
||||
): ObzvonOtvet
|
||||
```
|
||||
|
||||
Плюс появилось **одно** место приведения номера — `private static
|
||||
odinVidNomera()`, и оба потребителя берут его оттуда: и `klyuchZadaniya()`, и
|
||||
тело задания в `pozvonit()`.
|
||||
|
||||
### 2.1. Решающий довод, которого в задании нет
|
||||
|
||||
Второй путь — «знак остаётся доводом, но `pozvonit` проверяет, что он сходится»
|
||||
— **всё равно требует сменить подпись**. Чтобы сверить знак, надо его
|
||||
пересчитать, а для этого нужны арендатор, кампания и номер попытки. Их сегодня в
|
||||
`pozvonit` нет. То есть второй путь платит **ту же самую цену** (смена подписи),
|
||||
а взамен даёт:
|
||||
|
||||
- **пять доводов вместо четырёх**, причём пятый — производная от остальных
|
||||
четырёх, и единственное, чем он может отличаться, — быть неверным;
|
||||
- **новый исход отказа** «знак не сходится → дверь закрыта». А чем кончается
|
||||
«дверь закрыта»? Человеку не позвонили. То есть второй путь не убирает вред, а
|
||||
делает его громким. Громкий лучше тихого, но «невозможно» лучше громкого.
|
||||
|
||||
Раз цена одинаковая, а выигрыш только у первого — выбор не близкий.
|
||||
|
||||
### 2.2. Что путь 1 стоит, говорю вслух
|
||||
|
||||
- Клиент связи теперь знает доменные слова «арендатор», «кампания», «попытка».
|
||||
Лёгкий запах слоёв. Но `klyuchZadaniya()` **уже живёт в этом же классе и уже
|
||||
знает ровно эти четыре вещи** — новых знаний в класс не вошло, они лишь
|
||||
перестали ходить вокруг него.
|
||||
- Зовущий больше не может подставить произвольный знак. Если когда-нибудь
|
||||
понадобится знак из внешнего источника — это будет отдельное решение владельца,
|
||||
и оно упрётся в сторожа «В» (§3), а не проскочит молча.
|
||||
- Зовущему по-прежнему есть чем записать знак в свою таблицу:
|
||||
`ObzvonClient::klyuchZadaniya()` остался открытым, и он **обязан** совпасть с
|
||||
отправленным — обе величины выходят из одной функции от одних доводов.
|
||||
|
||||
### 2.3. 🔴 Р124 — что я об этом думаю (не переоткрываю, называю следствие)
|
||||
|
||||
Владелец назвал следствие сам, и оно верное. Добавлю точную границу правила,
|
||||
которое будет назначаться позже:
|
||||
|
||||
> **Номер попытки нельзя прибавлять на исходе, где `zvonokMogSostoyatsya()`
|
||||
> вернул «да».** Это «робот молчит», 5xx и «ответ без условленного слова ok».
|
||||
> Прибавь портал попытку здесь — знак станет другим, робот не узнает повтор и
|
||||
> наберёт номер второй раз, то есть защита не сработает **именно в том
|
||||
> единственном случае, ради которого заведена**.
|
||||
>
|
||||
> Прибавлять попытку можно только после исхода, где звонка **точно** не было
|
||||
> (дверь закрыта, занято, отказ), либо после состоявшегося и завершённого
|
||||
> разговора, который решили повторить.
|
||||
|
||||
Моя работа этого правила **не делает невозможным** — наоборот. Пока знак приходил
|
||||
готовым доводом, правило пряталось внутри того, кто его считал. Теперь
|
||||
`popytkaNo` — **отдельный видимый довод в каждом вызове**, и правило будет
|
||||
исполняться на глазах, а не по памяти. Это записано 🪤-абзацем в докблоке
|
||||
`pozvonit`, чтобы будущий работник очереди прочёл его в ту же секунду, когда
|
||||
пишет вызов.
|
||||
|
||||
Ещё одна граница Р124, которую стоит держать в уме: робот помнит знаки **сутки**.
|
||||
Повтор той же попытки, дошедший позже суток (застрявшая джоба, ручной перезапуск
|
||||
назавтра), робот уже не узнает и наберёт номер снова. Это окно робота, оно
|
||||
названо в самом Р124, и портальной починкой не закрывается.
|
||||
|
||||
---
|
||||
|
||||
## 3. Чем ронял сторожей и что они сказали
|
||||
|
||||
Ронял **работой, а не текстом**: возвращал беду в код и смотрел на то, что
|
||||
уходит подставному роботу. Слепков по тексту исходника я в новых сторожах не
|
||||
делал вовсе — предупреждение из задания принято.
|
||||
|
||||
### Поломка №1 — вернул в тело номер «как есть»
|
||||
|
||||
`'telefon' => $nomer` → `'telefon' => trim($telefon)` (ровно прежнее поведение).
|
||||
|
||||
Первый замер — **3 красных из 55**. Главный сторож остался зелёным, это моя
|
||||
ошибка (§0). После усиления сторожа тем же ломом — **4 красных из 55**, дословно:
|
||||
|
||||
```
|
||||
🔴 Б: тот же человек, записанный двумя видами — и знак, и ТЕЛО совпадают
|
||||
Failed asserting that two strings are identical.
|
||||
- '+7 999 000-00-01'
|
||||
+ '79990000001'
|
||||
|
||||
🔴 В: чем клиента ни зови — знак в теле всегда сходится с телефоном в теле
|
||||
Failed asserting that '+7 999 000-00-01' matches PCRE pattern "/^\d+$/".
|
||||
|
||||
🔴 в теле задания номер без украшений — того же вида, из какого посчитан знак
|
||||
Failed asserting that two strings are identical.
|
||||
- '79990000001'
|
||||
+ '+7 (999) 000-00-01'
|
||||
|
||||
🔴 «что ещё сказать роботу» не может перебить знак или номер
|
||||
Failed asserting that two strings are identical.
|
||||
- '79991234567'
|
||||
+ '+7 999 123-45-67'
|
||||
```
|
||||
|
||||
### Поломка №2 — вернул знак доводом снаружи
|
||||
|
||||
Добавил шестым доводом `string $klyuchZadaniya = ''` (то есть приоткрыл ту самую
|
||||
дверь, через которую и приходила рассогласованная пара). **1 красный из 55**,
|
||||
дословно:
|
||||
|
||||
```
|
||||
🔴 В: рассогласовать знак и телефон нечем — знак снаружи не задаётся вовсе
|
||||
Failed asserting that two arrays are identical.
|
||||
@@ @@
|
||||
2 => 'telefon',
|
||||
3 => 'popytkaNo',
|
||||
4 => 'dopolnitelno',
|
||||
+ 5 => 'klyuchZadaniya',
|
||||
]
|
||||
```
|
||||
|
||||
Этот сторож читает **сам доводный ряд метода** через `ReflectionMethod`, а не
|
||||
текст файла. Слово `klyuchZadaniya` в файле встречается многократно (в том числе
|
||||
в моих же пояснениях) — текстовый сторож зеленел бы всегда.
|
||||
|
||||
### Возврат — доказан слепком со снятием `\r`
|
||||
|
||||
После возврата обеих поломок:
|
||||
|
||||
```
|
||||
tr -d '\r' < app/app/Services/Obzvon/ObzvonClient.php | git hash-object --stdin
|
||||
→ cad7cd0d6288ff279159b9197f799a44e276a2d0
|
||||
tr -d '\r' < app/tests/Feature/Obzvon/ObzvonClientShovTest.php | git hash-object --stdin
|
||||
→ 1208cbda5ccb80eb2a9aa2f4bc59601194e39eb7
|
||||
```
|
||||
|
||||
Проба по своим сторожам после возврата: **55 проверок, 55 зелёных, 137 условий,
|
||||
красных 0.** Ни одной строки поломок в файле не осталось — проверено поиском по
|
||||
обеим внесённым приметам, найдено 0.
|
||||
|
||||
### Прочие мерки по своим файлам
|
||||
|
||||
- `vendor/bin/pint --test` по обоим файлам — **passed**.
|
||||
- `composer stan` (Larastan, весь `app/`) — **0 ошибок**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Числа полного прогона
|
||||
|
||||
**Своя база:** `liderra_testing_znak_telo` (создана, накатана `migrate --force`).
|
||||
Ни одна из занятых баз не тронута. Прогон один, других проб на этой базе
|
||||
параллельно не запускал.
|
||||
|
||||
Команда:
|
||||
|
||||
```
|
||||
DB_DATABASE=liderra_testing_znak_telo php -d memory_limit=-1 vendor/bin/pest
|
||||
```
|
||||
|
||||
| Что | Число |
|
||||
|---|---|
|
||||
| Всего проверок | **5293** |
|
||||
| Зелёных | **5289** |
|
||||
| **Красных** | **0** |
|
||||
| Ошибок | **0** |
|
||||
| Пропущено | 4 |
|
||||
| «Сомнительных» (risky) | 4 |
|
||||
| Условий проверено | 21 798 |
|
||||
| Время | 1 214 с (≈20 мин) |
|
||||
|
||||
**Арифметика сходится:** 5289 + 0 + 0 + 4 = **5293** = всего. Считал не по коду
|
||||
возврата, а по числам в самом выводе — красных, спрятавшихся в поле «ошибок»,
|
||||
нет: поле пустое.
|
||||
|
||||
**Сверка с входным замером.** На входе владелец назвал 5242 проверки, красных 0.
|
||||
Стало 5293 — **+51**. Из них **+8 мои** (в моём файле сторожей было 47 `it(`,
|
||||
стало 55 — замерено `git show HEAD:… | grep -c`), остальные **+43** принесли
|
||||
соседние смены, работающие в этой же ветке.
|
||||
|
||||
**4 пропущенных и 4 «сомнительных» — не мои и не новые.** Ровно те же числа
|
||||
лежат в выводах прогонов предыдущих смен этого дня (5207/5203/4/4 и
|
||||
5242/5238/4/4). Мой прогон их не изменил.
|
||||
|
||||
🔴 **Красноты в `Render`/`Autopodbor` не было** — сосед, правящий дверь боевого
|
||||
рендера, ничего мне не уронил, и мне нечего было бы расследовать.
|
||||
|
||||
---
|
||||
|
||||
## 5. Соседи
|
||||
|
||||
моего имени нет ни в одном выводе ни одного сторожа
|
||||
|
||||
чужие файлы в дереве не тронуты
|
||||
|
||||
Правил ровно три файла, все три — мои по заданию:
|
||||
`app/app/Services/Obzvon/ObzvonClient.php`,
|
||||
`app/tests/Feature/Obzvon/ObzvonClientShovTest.php` и этот отчёт. Запрещённых
|
||||
файлов не касался: ни `SelfRenderClient.php`, ни `config/services.php`, ни
|
||||
`.env.example` (два моих сторожа их только ЧИТАЮТ — так было и до меня), ни
|
||||
папки `bots/`, ни схемы, ни миграций. Правил только средствами правки
|
||||
Edit/Write, ни одной строки через оболочку.
|
||||
|
||||
---
|
||||
|
||||
## 6. Зовущие поимённо
|
||||
|
||||
**Спросил у прибора, что он считает** — и это важно, потому что упоминаний и
|
||||
вправду сильно больше, чем вызовов.
|
||||
|
||||
Прибор: `rg` (через поиск по дереву), запрос `pozvonit|klyuchZadaniya|ObzvonClient`,
|
||||
без `vendor/` и `node_modules/`. Он считает **упоминания в тексте**, а не вызовы:
|
||||
в его выдаче лежат и докблоки, и комментарии, и `README`, и питон робота, где
|
||||
`pozvonit` — это имя ручки HTTP, а не метод PHP. Поэтому глазами разбирал каждую
|
||||
строку.
|
||||
|
||||
### Зовущие `ObzvonClient::pozvonit()` — вызовы, поимённо
|
||||
|
||||
| Файл | Что это |
|
||||
|---|---|
|
||||
| `app/tests/Feature/Obzvon/ObzvonClientShovTest.php` | **единственный зовущий**, мой собственный файл сторожей |
|
||||
|
||||
**Боевых зовущих — НОЛЬ.** Замер владельца подтверждаю: смена подписи не сломала
|
||||
ничего, кроме собственных сторожей, которые я в этой же работе и переписал.
|
||||
|
||||
### Зовущие `ObzvonClient::klyuchZadaniya()` — вызовы, поимённо
|
||||
|
||||
| Файл | Что это |
|
||||
|---|---|
|
||||
| `app/app/Services/Obzvon/ObzvonClient.php` | сам себя — изнутри `pozvonit()` (это и есть починка) |
|
||||
| `app/tests/Feature/Obzvon/ObzvonClientShovTest.php` | сторожа |
|
||||
|
||||
### Упоминания, которые вызовами НЕ являются (чтобы не сосчитать их зовущими)
|
||||
|
||||
| Файл | Почему не вызов |
|
||||
|---|---|
|
||||
| `bots/lena-golos/priyomnik.py` | питон робота; `pozvonit` там — имя HTTP-ручки, `ObzvonClient` — в пояснениях |
|
||||
| `bots/lena-golos/proba-priyomnika.py` | то же, ~45 упоминаний имени ручки |
|
||||
| `bots/lena-golos/README.md` | описание шва |
|
||||
| `app/config/services.php` | комментарии-указатели |
|
||||
| `app/.env.example` | комментарий про хвост ручки |
|
||||
| `app/app/Services/Autopodbor/Agent/Fetch/DverRendera.php` | `{@see}` в докблоке (сосед, не тронут) |
|
||||
| `app/app/Services/Obzvon/ObzvonOtvet.php` | `{@see}` в докблоке |
|
||||
| `docs/superpowers/priyomka/stroyka-6/*.md` | приёмочные листы |
|
||||
|
||||
Если считать «упоминания», зовущих выходит **больше десяти**. Вызовов — **два
|
||||
файла на оба метода**, и оба мои. Именно на этой разнице сегодня обжёгся
|
||||
надзиратель, о чём предупреждало задание.
|
||||
|
||||
---
|
||||
|
||||
## 7. Что осталось незакрытым
|
||||
|
||||
1. 🔴 **`8…` и `+7…` — сегодня разные люди для портала.** Один живой человек,
|
||||
записанный двумя приставками, получит **два знака и два звонка**. Не чинил
|
||||
намеренно (§1.4): решение «какие записи значат одного человека» — владельца, и
|
||||
ошибка в противоположную сторону (слияние двух РАЗНЫХ людей в один знак)
|
||||
тише и хуже. **Вопрос владельцу.**
|
||||
2. 🔴 **`dopolnitelno` обязано быть одинаковым у повтора** (§1.5). Клиент это
|
||||
гарантировать не может — содержимое даёт зовущий. Предупреждение написано в
|
||||
докблоке `pozvonit`; сторожа на это нет и быть не может, пока нет зовущего.
|
||||
3. 🔴 **Правило счёта попыток не назначено** (Р124). Моя работа его не делает
|
||||
невозможным и выносит `popytkaNo` на видное место (§2.3), но само правило —
|
||||
за владельцем, и **до него защита от двойного звонка не замкнута**.
|
||||
4. 🔴 **Окно памяти робота — сутки.** Повтор той же попытки позже суток робот не
|
||||
узнает и наберёт снова. Портальной починкой не лечится.
|
||||
5. 🪤 **Защита по-прежнему не замкнута в бою**: доказано только то, что портал
|
||||
шлёт согласованную пару. Что робот и вправду узнаёт повтор — проверяется на
|
||||
стороне З-0.6, живого обмена между половинами не было.
|
||||
6. **Зовущего у `pozvonit` до сих пор нет ни одного.** Работник очереди — отдельная
|
||||
задача; пока он не написан, правильность применения (тот же `popytkaNo` на
|
||||
повторе, то же `dopolnitelno`) ничем не охраняется.
|
||||
Reference in New Issue
Block a user