diff --git a/app/.env.example b/app/.env.example index 21f5ea37..531ffff6 100644 --- a/app/.env.example +++ b/app/.env.example @@ -153,6 +153,15 @@ SMS_BEELINE_PASSWORD= SMS_BEELINE_TOKEN= SMS_BEELINE_NAMING= SMS_BEELINE_PRICE_KOP=0 +# Обратный звонок Билайна о судьбе: POST /api/webhook/beeline-delivery/{секрет} +# с полями ORDID, CNRID, RESCOUNT, STATUS, FINALTIME. Секрет — не короче 32 знаков; +# пустой = приёмник ЗАКРЫТ (судьбу доберёт опрос). Сам адрес надо заявить в кабинете: +# Настройки → Дополнительно → «Приём статусов отправленных сообщений». +# 🪤 Повторяет ли платформа неудавшийся звонок — в её описании не сказано ни слова: +# считаем, что пропущенный звонок потерян. +# Адреса отправителя — через запятую, можно подсетями; пусто = не проверяем. +SMS_BEELINE_WEBHOOK_SECRET= +SMS_BEELINE_WEBHOOK_IPS= # Реклама в Телеграме по своей базе (клиентский модуль, робот-в-браузере — у МТС нет API). # Песочница по умолчанию ВКЛ — робот доводит только до черновика, деньги не списываются. diff --git a/app/app/Http/Controllers/Api/BeelineDeliveryWebhookController.php b/app/app/Http/Controllers/Api/BeelineDeliveryWebhookController.php new file mode 100644 index 00000000..a087c605 --- /dev/null +++ b/app/app/Http/Controllers/Api/BeelineDeliveryWebhookController.php @@ -0,0 +1,216 @@ +verifySecret($secret)) { + return $this->notFound(); + } + + if (! $this->verifyIpAllowlist($request->ip())) { + Log::warning('client_sms.delivery_hook_foreign_ip', [ + 'provider' => self::PROVIDER_KEY, + 'ip' => $request->ip(), + ]); + + return $this->notFound(); + } + + $this->noteOpenGate($request->ip()); + + $rateKey = 'beeline-delivery-hook:'.($request->ip() ?? 'unknown'); + if (RateLimiter::tooManyAttempts($rateKey, self::RATE_LIMIT_PER_MINUTE)) { + $retryAfter = RateLimiter::availableIn($rateKey); + + return response()->json(['message' => 'Превышен лимит запросов.'], 429) + ->header('Retry-After', (string) $retryAfter); + } + RateLimiter::hit($rateKey, 60); + + $parser = $this->parser($router); + + if ($parser === null) { + // Канал не поднят (выключен или песочница) — разбирать звонок нечем. + // Молчать нельзя (урок В-121), но и отказывать платформе незачем. + Log::warning('client_sms.delivery_hook_no_channel', ['provider' => self::PROVIDER_KEY]); + + return $this->accepted(); + } + + // Тело и строки запроса разом: поля платформа шлёт формой, но брать только + // тело значило бы гадать о её привычках. + /** @var array $params */ + $params = $request->all(); + + $reports = $parser->parseDeliveryPayload($params); + + if ($reports === []) { + // Звонок не узнан. Пишем его ФОРМУ — перечень имён полей, без значений. + Log::warning('client_sms.delivery_hook_unparsed', [ + 'provider' => self::PROVIDER_KEY, + 'keys' => array_keys($params), + 'size' => count($params), + ]); + + return $this->accepted(); + } + + $updated = $writer->applyMany(self::PROVIDER_KEY, $reports); + + // «Успех с нулём» — не повод молчать (урок В-121): пришёл отчёт про + // сообщение, которого мы не знаем. + if ($updated === 0) { + Log::warning('client_sms.delivery_hook_unknown_messages', [ + 'provider' => self::PROVIDER_KEY, + 'reports' => count($reports), + ]); + } + + return $this->accepted(); + } + + /** + * Чего платформа ждёт в ответ, её описание не говорит — отдаём самое дешёвое: + * 204, пустое тело. Тот же ответ, что у приёмников МТС и t2: три приёмника, + * одна привычка. + */ + private function accepted(): Response + { + return response()->noContent(); + } + + private function notFound(): JsonResponse + { + return response()->json(['message' => 'Not found.'], 404); + } + + /** Канал, умеющий разобрать звонок о судьбе. Нет такого — null. */ + private function parser(SmsRouter $router): ?SmsDeliveryPayloadParser + { + foreach ($router->providers() as $provider) { + if ($provider instanceof SmsDeliveryPayloadParser && $provider->key() === self::PROVIDER_KEY) { + return $provider; + } + } + + return null; + } + + private function verifySecret(string $provided): bool + { + $expected = (string) config('services.sms.beeline.webhook_secret', ''); + + // Не задан или слишком короток — приёмник считается ненастроенным и не + // работает вовсе. Это НЕ «пускать всех»: адрес публичный, а за ним деньги. + if (strlen($expected) < 32) { + return false; + } + + return hash_equals($expected, $provided); + } + + /** + * Пока белый список пуст, отказа никому нет — а значит, и записи, с какого + * адреса звонит платформа, взяться неоткуда: заполнить список было бы нечем. + * Разрываем круг — пишем адрес звонившего. Как только список задан, смолкает. + * + * 🪤 Уровень именно «предупреждение», а не «к сведению»: на боевом + * LOG_LEVEL=warning, и всё, что ниже, молча выбрасывается. Написанная + * уровнем ниже строка не попала бы в журнал НИКОГДА — круг остался бы + * замкнут, только теперь незаметно. Замерено живым вызовом на бою + * 04.08.2026: соседнее предупреждение легло, а «к сведению» — нет. + */ + private function noteOpenGate(?string $clientIp): void + { + if (trim((string) config('services.sms.beeline.webhook_ips', '')) !== '') { + return; + } + + Log::warning('client_sms.delivery_hook_open_gate', [ + 'provider' => self::PROVIDER_KEY, + 'ip' => $clientIp, + ]); + } + + private function verifyIpAllowlist(?string $clientIp): bool + { + $raw = trim((string) config('services.sms.beeline.webhook_ips', '')); + + // Список пуст — адреса платформы нам ещё неизвестны, проверять нечем. + // Защита держится на секрете; список включается одной строкой в `.env`. + if ($raw === '') { + return true; + } + + if ($clientIp === null) { + return false; + } + + $list = array_values(array_filter(array_map(trim(...), explode(',', $raw)))); + + return $list === [] || IpUtils::checkIp($clientIp, $list); + } +} diff --git a/app/config/services.php b/app/config/services.php index c8aa4e0b..541234c7 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -411,6 +411,17 @@ return [ 'enabled' => (bool) env('SMS_BEELINE_ENABLED', false), 'base_url' => env('SMS_BEELINE_BASE_URL', 'https://a2p-sms-https.beeline.ru/proto/http/rest'), 'naming' => env('SMS_BEELINE_NAMING', ''), + // Обратный звонок о судьбе (строка листа 5.2): платформа сама зовёт + // /api/webhook/beeline-delivery/{secret} методом POST, поля ORDID, + // CNRID, RESCOUNT, STATUS, FINALTIME. Адрес заявляется в кабинете: + // Настройки → Дополнительно → «Приём статусов отправленных сообщений». + // Секрет короче 32 знаков или пустой = приёмник ЗАКРЫТ наглухо. + // 🪤 Повторяет ли платформа неудавшийся звонок — в её описании НЕ + // сказано ни слова; считаем худшее, пропущенный доберёт только опрос. + // Адреса отправителя — через запятую, можно подсетями; пуст = не + // проверяем (адреса платформы нам неизвестны, выдумывать нельзя). + 'webhook_secret' => env('SMS_BEELINE_WEBHOOK_SECRET', ''), + 'webhook_ips' => env('SMS_BEELINE_WEBHOOK_IPS', ''), ], // Канал Теле2 = «SMS-Таргет» (target.t2.ru), HTTP API v2, вход по логину и // паролю (Basic Auth) из раздела кабинета «Профиль → API-рассылки». diff --git a/app/routes/web.php b/app/routes/web.php index 6aaa4c9f..e9935cf2 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -691,6 +691,18 @@ Route::post('/api/webhook/mts-delivery/{secret}', 'App\Http\Controllers\Api\MtsD Route::match(['get', 'post'], '/api/webhook/t2-delivery/{secret}', 'App\Http\Controllers\Api\T2DeliveryWebhookController@receive') ->where('secret', '[A-Za-z0-9_\-]+'); +// Обратный звонок Билайна о судьбе СМС (Этап 5, строка листа 5.2). Форма ТРЕТЬЯ: +// описание платформы прямо называет POST по HTTPS, поля ORDID / CNRID / RESCOUNT / +// STATUS / FINALTIME, судьба ЧИСЛОМ, время в UTC. Отсюда один глагол, а не два, +// как у t2: там форма живьём известна не была, здесь названа. Защита та же: секрет +// в адресе (≥32 знаков) + необязательный список адресов отправителя. +// Адрес заявляется в кабинете: Настройки → Дополнительно → «Приём статусов +// отправленных сообщений». +// 🪤 Повторяет ли платформа неудавшийся звонок — в её описании НЕ сказано, +// поэтому опрос `client-sms:poll-delivery` здесь тем более обязателен. +Route::post('/api/webhook/beeline-delivery/{secret}', 'App\Http\Controllers\Api\BeelineDeliveryWebhookController@receive') + ->where('secret', '[A-Za-z0-9_\-]+'); + // Платёжный webhook (ЮKassa). Публичный, под маской api/webhook/* → CSRF-exempt. // Подлинность — server-to-server сверкой статуса (не доверяем телу). Plan billing-yookassa Task 7. Route::post('/api/webhook/payment', 'App\Http\Controllers\Api\PaymentWebhookController@receive'); diff --git a/app/tests/Feature/ClientSms/BeelineDeliveryWebhookTest.php b/app/tests/Feature/ClientSms/BeelineDeliveryWebhookTest.php new file mode 100644 index 00000000..819b3334 --- /dev/null +++ b/app/tests/Feature/ClientSms/BeelineDeliveryWebhookTest.php @@ -0,0 +1,268 @@ + BEE_HOOK_SECRET]); + config(['services.sms.beeline.webhook_ips' => '']); + + // Разбор живёт в КАНАЛЕ. В песочнице маршрутизатор держит только заглушку, + // поэтому канал Билайна ставим руками — в сеть он здесь не ходит. + app()->instance(SmsRouter::class, new SmsRouter([ + new BeelineSmsProvider('login-not-used-here', 'pass', ['beeline'], ['*' => 475]), + ])); +}); + +afterEach(function () { + Carbon::setTestNow(); +}); + +function beeHookUrl(string $secret = BEE_HOOK_SECRET): string +{ + return '/api/webhook/beeline-delivery/'.$secret; +} + +function beeHookMessage(string $providerMessageId = '508909732338141600334', string $phone = '79990000001'): ClientSmsMessage +{ + $tenant = Tenant::factory()->create(); + + $campaign = ClientSmsCampaign::create([ + 'tenant_id' => $tenant->id, + 'title' => 'Отчёт от Билайна', + 'body' => 'Текст', + 'sender_name' => 'Liderra.ru', + 'source' => ClientSmsCampaign::SOURCE_MANUAL, + 'status' => ClientSmsCampaign::STATUS_DONE, + 'idempotency_key' => 'bee-hook-'.uniqid(), + 'segments' => 1, + 'planned_count' => 1, + 'sent_count' => 1, + 'total_sms' => 1, + 'price_rub_per_sms' => '4.75', + 'estimated_cost_rub' => '4.75', + ]); + + return ClientSmsMessage::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'phone' => $phone, + 'operator' => 'beeline', + 'provider_key' => 'beeline', + 'status' => ClientSmsMessage::STATUS_SENT, + 'provider_message_id' => $providerMessageId, + 'cost_rub' => '4.75', + 'segments' => 1, + ]); +} + +it('принимает звонок Билайна, правит строку журнала и отвечает без содержимого', function () { + $message = beeHookMessage(); + + $response = $this->post(beeHookUrl(), [ + 'ORDID' => '508909732338141600334', + 'CNRID' => '1046802', + 'RESCOUNT' => '2', + 'STATUS' => '2', + 'FINALTIME' => '15.08.2026 09:30:00', + ]); + + $response->assertNoContent(); + + $message->refresh(); + expect($message->delivery_status)->toBe(SmsDeliveryState::DELIVERED) + ->and($message->delivery_raw)->toBe('2') + ->and($message->provider_parts)->toBe(2) + ->and($message->delivered_at)->not->toBeNull() + ->and($message->delivery_checked_at)->not->toBeNull(); +}); + +it('«оператор не принял» — это НЕ доставлено, а не ушло вовсе', function () { + // За неотправленное клиент платить не должен (В-212): судьбы «не доставлено» + // и «не ушло» разные, и возврат за них считается по-разному. + $message = beeHookMessage(); + + $this->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '8']) + ->assertNoContent(); + + expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::NOT_SENT); +}); + +it('«судьба неизвестна» судьбу НЕ проставляет', function () { + // 7 — платформа сама не знает. Это не «не дошло»; догадка тут стоила бы + // ложного возврата денег. + $message = beeHookMessage(); + + $this->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '7']) + ->assertNoContent(); + + $message->refresh(); + expect($message->delivery_status)->toBeNull() + ->and($message->delivery_raw)->toBe('7'); +}); + +it('не пускает чужой адрес и журнал не трогает', function () { + config(['services.sms.beeline.webhook_ips' => '203.0.113.10, 203.0.113.11']); + $message = beeHookMessage(); + + $response = $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.9']) + ->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '2']); + + // 404, а не 403: существование адреса чужому не подтверждаем. + $response->assertNotFound(); + expect($message->refresh()->delivery_status)->toBeNull(); +}); + +it('пускает адрес из списка разрешённых', function () { + config(['services.sms.beeline.webhook_ips' => '203.0.113.10, 198.51.100.0/24']); + $message = beeHookMessage(); + + $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.9']) + ->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '2']) + ->assertNoContent(); + + expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::DELIVERED); +}); + +/** + * Пока белый список пуст, ворота открыты всем — и адрес звонившего негде взять: + * отказа нет, значит и записи «пришли с такого-то адреса» нет. Круг замкнут, + * список не заполнить никогда. Разрываем его: при пустом списке пишем адрес. + */ +it('при пустом белом списке записывает адрес звонившего', function () { + config(['services.sms.beeline.webhook_ips' => '']); + Log::spy(); + beeHookMessage(); + + $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.77']) + ->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '2']) + ->assertNoContent(); + + // Уровень именно «предупреждение». На боевом LOG_LEVEL=warning, и всё, что + // ниже, молча выбрасывается: записанная уровнем «к сведению» строка не + // появилась бы НИКОГДА — замерено живым вызовом на бою 04.08.2026. + /* @phpstan-ignore-next-line staticMethod.notFound */ + Log::shouldHaveReceived('warning') + ->withArgs(fn ($msg, $ctx) => $msg === 'client_sms.delivery_hook_open_gate' + && $ctx['provider'] === 'beeline' + && $ctx['ip'] === '203.0.113.77') + ->once(); +}); + +it('когда белый список задан — про адрес молчит', function () { + config(['services.sms.beeline.webhook_ips' => '203.0.113.0/24']); + Log::spy(); + beeHookMessage(); + + $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.77']) + ->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '2']) + ->assertNoContent(); + + // Молчит именно про адрес: прочие предупреждения приёмника этот тест не трогает. + /* @phpstan-ignore-next-line staticMethod.notFound */ + Log::shouldNotHaveReceived('warning', [ + 'client_sms.delivery_hook_open_gate', + Mockery::any(), + ]); +}); + +it('не пускает с неверным секретом и журнал не трогает', function () { + $message = beeHookMessage(); + + $response = $this->post( + beeHookUrl('sovsem-drugoy-sekret-0123456789abcd'), + ['ORDID' => '508909732338141600334', 'STATUS' => '2'], + ); + + $response->assertNotFound(); + expect($message->refresh()->delivery_status)->toBeNull(); +}); + +it('закрыт наглухо, пока секрет не задан', function () { + config(['services.sms.beeline.webhook_secret' => '']); + $message = beeHookMessage(); + + // Пустой секрет — это «приёмник не настроен», а не «пускать всех». + $response = $this->post( + beeHookUrl('chto-ugodno-0123456789abcdefghij'), + ['ORDID' => '508909732338141600334', 'STATUS' => '2'], + ); + + $response->assertNotFound(); + expect($message->refresh()->delivery_status)->toBeNull(); +}); + +it('на звонок про незнакомое сообщение не падает', function () { + $message = beeHookMessage(providerMessageId: '508909732338141600334'); + + $this->post(beeHookUrl(), ['ORDID' => '999999999999999999999', 'STATUS' => '2']) + ->assertNoContent(); + + expect($message->refresh()->delivery_status)->toBeNull(); +}); + +it('на звонок непонятной формы не пишет ничего', function () { + $message = beeHookMessage(); + + $this->post(beeHookUrl(), ['message_uid' => '508909732338141600334', 'state' => 'ok']) + ->assertNoContent(); + + $message->refresh(); + expect($message->delivery_status)->toBeNull() + ->and($message->delivery_raw)->toBeNull() + ->and($message->delivery_checked_at)->toBeNull(); +}); + +it('денег НЕ двигает — возврат остаётся делом опроса', function () { + $message = beeHookMessage(); + + $this->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '5']) + ->assertNoContent(); + + $message->refresh(); + expect($message->delivery_status)->toBe(SmsDeliveryState::NOT_DELIVERED) + ->and($message->refunded_at)->toBeNull() + // Деньги живут в ОДНОМ доме — в команде опроса (В-146). + // 🪤 Считаем проводки СВОЕГО клиента: рекламные тесты идут без отката и + // оставляют свои записи в базе (урок из MtsDeliveryWebhookTest). + ->and(AdWalletTransaction::where('tenant_id', $message->tenant_id)->count())->toBe(0); +});