merge: имя отправителя выбирается по оператору получателя
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled

Ветка feat/sms-imena-otpravitelya-po-kanalam, одна запись. Закрыта общая причина
отказа Теле2 invalid_source_address: имя согласовывает оператор, и у каждого оно
своё, а клиентская рассылка несла одно имя всем с умолчанием от МТС.

Проверено перед сведением: 4614 тестов (упал один чужой — в рабочей папке не
собран фронтенд), статанализ 0, удалений чужих строк нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-05 06:46:01 +03:00
12 changed files with 435 additions and 4 deletions
+3
View File
@@ -118,6 +118,9 @@ SMS_SANDBOX=true
SMS_FAILOVER_TO_UNIVERSAL=false
SMS_SMSC_ENABLED=false
SMS_SMSC_PRICE_KOP=0
# Имя отправителя универсального канала — им подписываемся, когда своего имени
# у клиента для сети получателя нет. Пусто = уйдёт без имени.
SMSC_NAMING=
SMS_MTS_ENABLED=false
SMS_MTS_TOKEN=
SMS_MTS_PRICE_KOP=0
@@ -19,6 +19,15 @@ trait ResolvesClientSmsSenderName
/**
* Эффективное имя отправителя: активное согласованное имя тенанта, иначе
* согласованное имя МТС из конфига или liderra.ru по умолчанию.
*
* 🪤 Это ЭКРАННОЕ имя и запись намерения (`campaign.sender_name`), а НЕ то,
* чем подпишется каждая сеть. Чем подписаться в сети конкретного оператора,
* решает `App\Services\ClientSms\ClientSmsSenderResolver` в момент отправки:
* имя согласовывает оператор, и у каждого оно своё.
*
* Подставлять отсюда имя прямо в канал та самая ошибка, что дала живой
* отказ Теле2 `invalid_source_address` 04.08.2026: умолчание здесь взято
* от МТС и для других сетей неверно.
*/
protected function effectiveSenderName(int $tenantId): string
{
+12 -2
View File
@@ -11,6 +11,7 @@ use App\Models\Deal;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\ClientSmsPricing;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\ClientSms\ClientSmsSenderResolver;
use App\Services\ClientSms\SmsQuietHours;
use App\Services\DaData\DaDataBudgetGuard;
use App\Services\DaData\DaDataPhoneClient;
@@ -77,13 +78,18 @@ class SendAutoSmsForDealJob implements ShouldQueue
SmsQuietHours $quiet,
DaDataPhoneClient $daData,
DaDataBudgetGuard $budget,
?ClientSmsSenderResolver $senders = null,
): void {
// Необязательный довод: вызовы с шестью аргументами не ломаются, а в бою
// контейнер отдаёт тот же самый объект.
$senders ??= app(ClientSmsSenderResolver::class);
// Авто-СМС — best-effort: ЛЮБОЙ сбой логируем и выходим, НИКОГДА не роняем.
// Под sync-очередью (и в тестах) джоб может исполниться прямо в запросе
// создания лида — исключение отсюда сломало бы приём лида. Идемпотентность
// (deal_id + external_key) делает потерянный прогон безопасным для повтора.
try {
$this->run($selector, $pricing, $wallet, $quiet, $daData, $budget);
$this->run($selector, $pricing, $wallet, $quiet, $daData, $budget, $senders);
} catch (\Throwable $e) {
Log::warning('client_sms.auto_job_failed', [
'deal_id' => $this->dealId,
@@ -100,6 +106,7 @@ class SendAutoSmsForDealJob implements ShouldQueue
SmsQuietHours $quiet,
DaDataPhoneClient $daData,
DaDataBudgetGuard $budget,
ClientSmsSenderResolver $senders,
): void {
$sandbox = (bool) config('services.sms.sandbox');
@@ -224,10 +231,13 @@ class SendAutoSmsForDealJob implements ShouldQueue
$provider = $row['provider'];
try {
// Имя отправителя — ПО ОПЕРАТОРУ получателя (см. ClientSmsSenderResolver):
// согласовывает его оператор, и у каждого оно своё. Пустая строка означает
// «своего имени для этой сети нет» — канал подпишется собственным.
$result = $provider->send(new SmsOutgoing(
phone: $row['phone'],
body: $rule->body,
senderName: $rule->sender_name,
senderName: $senders->forOperator($this->tenantId, (string) $row['operator']),
operator: $row['operator'],
segments: $segments,
));
+13 -1
View File
@@ -10,6 +10,7 @@ use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsMessage;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\ClientSmsPricing;
use App\Services\ClientSms\ClientSmsSenderResolver;
use App\Services\ClientSms\ClientSmsSnapshotReader;
use App\Services\Sms\SmsOutgoing;
use App\Services\Sms\SmsSendException;
@@ -67,7 +68,12 @@ class SendClientSmsCampaignJob implements ShouldQueue
ClientSmsSnapshotReader $snapshot,
ClientSmsPricing $pricing,
AdWalletService $wallet,
?ClientSmsSenderResolver $senders = null,
): void {
// Необязательный довод: вызовы с тремя аргументами (тесты, старые места)
// не ломаются, а в бою контейнер отдаёт тот же самый объект.
$senders ??= app(ClientSmsSenderResolver::class);
$sandbox = (bool) config('services.sms.sandbox');
// Фаза A — под tenant-контекстом: статус и уже отправленное. Аудиторию
@@ -182,10 +188,16 @@ class SendClientSmsCampaignJob implements ShouldQueue
$provider = $row['provider'];
try {
// Имя отправителя — ПО ОПЕРАТОРУ получателя, а не одно на всех:
// согласовывает имя оператор, и у каждого оно своё. Пустая строка
// здесь означает «своего имени для этой сети нет» — канал подпишется
// собственным согласованным именем из настроек. Раньше сюда шло
// `campaign.sender_name`, умолчанием которого было имя МТС: Теле2
// отвечал на него `invalid_source_address` (живой отказ 04.08.2026).
$result = $provider->send(new SmsOutgoing(
phone: $row['phone'],
body: $campaign->body,
senderName: $campaign->sender_name,
senderName: $senders->forOperator($this->tenantId, (string) $row['operator']),
operator: $row['operator'],
segments: $segments,
));
+2
View File
@@ -151,6 +151,8 @@ class AppServiceProvider extends ServiceProvider
(string) config('services.smsc.password'),
$serves,
$price,
// Чем подписаться, если своего имени у клиента для этой сети нет.
(string) config('services.smsc.naming', ''),
)
: null,
'mts' => ($token = (string) config('services.sms.mts.token')) !== ''
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Services\ClientSms;
use App\Models\ClientSmsSender;
/**
* Каким именем подписать СМС клиента в сети КОНКРЕТНОГО оператора.
*
* Имя отправителя согласовывает оператор, а не канал: через универсальный
* СМС-центр можно слать в любую сеть, и спрашивать надо у той сети, чей номер.
*
* 🪤 До 05.08.2026 клиентская рассылка несла ОДНО имя всем каналам, а
* умолчанием служило `services.sms.mts.naming` имя, согласованное с МТС,
* подставлялось Теле2 и подставилось бы любому будущему каналу. Живой отказ
* Теле2 `invalid_source_address` 04.08.2026 ровно этот механизм.
*
* Пустая строка на выходе это НЕ «имени нет». Это «своего имени у клиента для
* этой сети нет, подпишись собственным именем канала»: каждый канал знает своё
* согласованное написание из настроек и сам подставляет его, когда имя пустое.
*/
final class ClientSmsSenderResolver
{
public function forOperator(int $tenantId, string $operator): string
{
$sender = ClientSmsSender::query()
->where('tenant_id', $tenantId)
->where('status', ClientSmsSender::STATUS_ACTIVE)
->first();
if ($sender === null) {
return '';
}
/** @var array<int, string>|null $operators */
$operators = $sender->operators;
// Список не заполнен — запись заведена до появления разбора по
// операторам. Отнять у неё имя молча значило бы поменять поведение
// задним числом, поэтому считаем «годится всем».
if ($operators === null || $operators === []) {
return (string) $sender->name;
}
return in_array($operator, $operators, true) ? (string) $sender->name : '';
}
}
@@ -28,6 +28,13 @@ final class SmscSmsProvider implements SmsProvider
private readonly string $password,
private readonly array $serves,
private readonly array $priceKopecks,
/**
* Своё согласованное имя канала. Подставляется, когда в сообщении имя
* пустое то есть «своего имени у клиента для сети получателя нет».
* До 05.08.2026 универсал слал имя КАК ЕСТЬ и ушёл бы с пустым
* отправителем, как только имена стали выбираться по оператору.
*/
private readonly string $naming = '',
) {}
public function key(): string
@@ -53,7 +60,7 @@ final class SmscSmsProvider implements SmsProvider
'psw' => $this->password,
'phones' => $message->phone,
'mes' => $message->body,
'sender' => $message->senderName,
'sender' => $message->senderName !== '' ? $message->senderName : $this->naming,
'charset' => 'utf-8',
'fmt' => 3,
]);
+5
View File
@@ -141,6 +141,11 @@ return [
'red_floor_rub' => (int) env('SMSC_RED_FLOOR_RUB', 300),
'amber_floor_rub' => (int) env('SMSC_AMBER_FLOOR_RUB', 1000),
'topup_url' => env('SMSC_TOPUP_URL', 'https://smsc.ru/oplata/'),
// Своё согласованное имя отправителя универсального канала. Подставляется,
// когда своего имени у клиента для сети получателя нет: имя согласовывает
// ОПЕРАТОР, а универсал ходит во все сети сразу (см. ClientSmsSenderResolver).
// Пусто — уйдёт без имени, как было до 05.08.2026.
'naming' => env('SMSC_NAMING', ''),
],
// AITUNNEL (OpenAI-совместимый) для автоподбора: эмбеддинги (похожесть конкурента на
+106
View File
@@ -7,6 +7,7 @@ use App\Models\AdWallet;
use App\Models\ClientSmsAutoRule;
use App\Models\ClientSmsMessage;
use App\Models\ClientSmsOptout;
use App\Models\ClientSmsSender;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
@@ -19,7 +20,10 @@ use App\Services\DaData\DaDataBudgetGuard;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\Sms\OperatorNormalizer;
use App\Services\Sms\Providers\MtsSmsProvider;
use App\Services\Sms\SmsOutgoing;
use App\Services\Sms\SmsProvider;
use App\Services\Sms\SmsRouter;
use App\Services\Sms\SmsSendResult;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
@@ -341,3 +345,105 @@ it('3.8 — авто-СМС считается по ступени с учёто
->and($m->segments)->toBe(1) // журнал помнит объём (В-108)
->and((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('992.00');
});
/**
* Канал, запоминающий, каким именем его просили подписать. Имя своё, не как в
* SendJobTest: помощники Pest ГЛОБАЛЬНЫЕ, одноимённые уронили бы прогон.
*/
// Без объявленного типа возврата НАМЕРЕННО: с ним анализатор видит только
// интерфейс и теряет поле $seenSenderNames, ради которого канал и заведён.
function autoRecordingProvider()
{
return new class implements SmsProvider
{
/** @var list<string> */
public array $seenSenderNames = [];
public function key(): string
{
return 'mts';
}
public function servesOperators(): array
{
return ['mts'];
}
public function priceKopecks(string $operator): int
{
return 0;
}
public function send(SmsOutgoing $message): SmsSendResult
{
$this->seenSenderNames[] = $message->senderName;
return new SmsSendResult('MSG-AUTO-SENDER-1', $message->segments, 0, CarbonImmutable::now());
}
};
}
it('авто-СМС: имя согласовано у ДРУГОГО оператора — каналу отдаём пустое', function () {
config(['services.sms.sandbox' => false]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
makeAutoRule((int) $tenant->id, true);
// Своё имя согласовано только у Теле2, а лид — из сети МТС.
ClientSmsSender::create([
'tenant_id' => $tenant->id,
'name' => 'mybrand.ru',
'name_type' => ClientSmsSender::TYPE_WEBSITE,
'status' => ClientSmsSender::STATUS_ACTIVE,
'operators' => ['tele2'],
'monthly_fee_rub' => '0.00',
'consent_at' => now(),
]);
$deal = makeFreshDeal((int) $tenant->id, '79990000021');
$provider = autoRecordingProvider();
$selector = new ClientSmsRecipientSelector(
new SmsRouter([$provider]),
new OperatorNormalizer,
new AllowedSmsOperators(new OperatorNormalizer),
);
runAutoJob((int) $deal->id, (int) $tenant->id, $selector);
// Сначала — что сравниваем не пустоту с пустотой: сообщение реально ушло.
expect($provider->seenSenderNames)->toHaveCount(1);
expect($provider->seenSenderNames[0])->toBe('');
});
it('авто-СМС: имя согласовано у ЭТОГО оператора — им и подписываем', function () {
config(['services.sms.sandbox' => false]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
makeAutoRule((int) $tenant->id, true);
ClientSmsSender::create([
'tenant_id' => $tenant->id,
'name' => 'mybrand.ru',
'name_type' => ClientSmsSender::TYPE_WEBSITE,
'status' => ClientSmsSender::STATUS_ACTIVE,
'operators' => ['mts'],
'monthly_fee_rub' => '0.00',
'consent_at' => now(),
]);
$deal = makeFreshDeal((int) $tenant->id, '79990000022');
$provider = autoRecordingProvider();
$selector = new ClientSmsRecipientSelector(
new SmsRouter([$provider]),
new OperatorNormalizer,
new AllowedSmsOperators(new OperatorNormalizer),
);
runAutoJob((int) $deal->id, (int) $tenant->id, $selector);
expect($provider->seenSenderNames)->toBe(['mybrand.ru']);
});
+105
View File
@@ -8,6 +8,7 @@ use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsContact;
use App\Models\ClientSmsMessage;
use App\Models\ClientSmsOptout;
use App\Models\ClientSmsSender;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\AllowedSmsOperators;
@@ -405,3 +406,107 @@ it('В-125 — снимок читается только под пометко
expect(array_filter($reads, fn ($underContext) => $underContext === false))->toBe([]);
});
/**
* Имя отправителя выбирается ПО ОПЕРАТОРУ получателя, а не одно на всех.
*
* До 05.08.2026 джоб слал `campaign.sender_name` каждому каналу, а умолчанием
* этого поля служило `services.sms.mts.naming` имя, согласованное с МТС.
* Теле2 отвечал на него `invalid_source_address` (живой отказ 04.08.2026).
*/
// Без объявленного типа возврата НАМЕРЕННО: с ним анализатор видит только
// интерфейс и теряет поле $seenSenderNames, ради которого канал и заведён.
function recordingSenderProvider(string $key, array $serves)
{
return new class($key, $serves) implements SmsProvider
{
/** @var list<string> */
public array $seenSenderNames = [];
public function __construct(private string $k, private array $serves) {}
public function key(): string
{
return $this->k;
}
public function servesOperators(): array
{
return $this->serves;
}
public function priceKopecks(string $operator): int
{
return 0;
}
public function send(SmsOutgoing $message): SmsSendResult
{
$this->seenSenderNames[] = $message->senderName;
return new SmsSendResult('MSG-SENDER-1', $message->segments, 0, CarbonImmutable::now());
}
};
}
it('имя клиента согласовано у ДРУГОГО оператора — каналу отдаём пустое, чтобы он подписался своим', function () {
config(['services.sms.sandbox' => false]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
// Своё имя согласовано только у Теле2, а шлём в сеть МТС.
ClientSmsSender::create([
'tenant_id' => $tenant->id,
'name' => 'mybrand.ru',
'name_type' => ClientSmsSender::TYPE_WEBSITE,
'status' => ClientSmsSender::STATUS_ACTIVE,
'operators' => ['tele2'],
'monthly_fee_rub' => '0.00',
'consent_at' => now(),
]);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
$provider = recordingSenderProvider('mts', ['mts']);
bindSendJobRoute($provider);
$campaign = makeSendJobCampaign($tenant->id, 1);
makeSendJobSnapshot($campaign);
runSendJob($campaign->id, $tenant->id);
// Сначала убеждаемся, что сравниваем не пустоту с пустотой: номер реально ушёл.
expect($provider->seenSenderNames)->toHaveCount(1);
// И имя МТС-кампании клиенту не подставили — канал подпишется своим.
expect($provider->seenSenderNames[0])->toBe('');
});
it('имя клиента согласовано у ЭТОГО оператора — им и подписываем', function () {
config(['services.sms.sandbox' => false]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
ClientSmsSender::create([
'tenant_id' => $tenant->id,
'name' => 'mybrand.ru',
'name_type' => ClientSmsSender::TYPE_WEBSITE,
'status' => ClientSmsSender::STATUS_ACTIVE,
'operators' => ['mts'],
'monthly_fee_rub' => '0.00',
'consent_at' => now(),
]);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
$provider = recordingSenderProvider('mts', ['mts']);
bindSendJobRoute($provider);
$campaign = makeSendJobCampaign($tenant->id, 1);
makeSendJobSnapshot($campaign);
runSendJob($campaign->id, $tenant->id);
expect($provider->seenSenderNames)->toBe(['mybrand.ru']);
});
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
use App\Models\ClientSmsSender;
use App\Models\Tenant;
use App\Services\ClientSms\ClientSmsSenderResolver;
use Illuminate\Foundation\Testing\DatabaseTransactions;
/**
* Имя отправителя согласовывает ОПЕРАТОР, а не канал: через универсальный
* СМС-центр можно слать в любую сеть, и спрашивать надо у той сети, чей номер.
*
* До 05.08.2026 клиентская рассылка несла ОДНО имя всем, а умолчанием служило
* `services.sms.mts.naming` то есть имя, согласованное с МТС, подставлялось
* Теле2 и любому будущему каналу. Живой отказ `invalid_source_address` от Теле2
* 04.08 ровно этот механизм.
*
* Пустая строка на выходе НЕ «нет имени», а «своего имени нет, подпишись
* собственным именем канала»: каждый канал знает своё согласованное написание
* из настроек (MtsSmsProvider: `senderName !== '' ? senderName : $this->naming`).
*/
uses(DatabaseTransactions::class);
function makeSenderFor(?array $operators, string $status = ClientSmsSender::STATUS_ACTIVE, string $name = 'mybrand.ru'): Tenant
{
$tenant = Tenant::factory()->create();
ClientSmsSender::create([
'tenant_id' => $tenant->id,
'name' => $name,
'name_type' => ClientSmsSender::TYPE_WEBSITE,
'status' => $status,
'operators' => $operators,
'monthly_fee_rub' => '0.00',
'consent_at' => now(),
]);
return $tenant;
}
it('своего имени нет вовсе — отдаёт пустую строку, чтобы канал подписался своим', function () {
$tenant = Tenant::factory()->create();
expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'tele2'))->toBe('');
});
it('имя согласовано у этого оператора — отдаёт его', function () {
$tenant = makeSenderFor(['mts', 'tele2']);
expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'tele2'))->toBe('mybrand.ru');
});
it('имя согласовано у ДРУГОГО оператора — своим именем не подписывает', function () {
$tenant = makeSenderFor(['mts']);
// Ровно тот случай, что дал живой отказ Теле2 04.08.2026.
expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'tele2'))->toBe('');
});
it('список операторов не заполнен — считаем, что имя годится всем', function () {
// Старые записи заведены до появления разбора по операторам: отнять у них
// имя молча — значит поменять поведение задним числом.
$tenant = makeSenderFor(null);
expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'megafon'))->toBe('mybrand.ru');
});
it('имя ещё на согласовании — не подписывает им', function () {
$tenant = makeSenderFor(['tele2'], ClientSmsSender::STATUS_PENDING);
expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'tele2'))->toBe('');
});
it('имя приостановлено за долг — не подписывает им', function () {
$tenant = makeSenderFor(['tele2'], ClientSmsSender::STATUS_SUSPENDED);
expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'tele2'))->toBe('');
});
it('чужое имя соседа не подставляет', function () {
makeSenderFor(['tele2'], ClientSmsSender::STATUS_ACTIVE, 'sosed.ru');
$mine = Tenant::factory()->create();
expect(app(ClientSmsSenderResolver::class)->forOperator($mine->id, 'tele2'))->toBe('');
});
@@ -28,6 +28,43 @@ it('обслуживает всех как универсал', function () {
->and(smsc_provider()->key())->toBe('smsc');
});
/**
* С 05.08.2026 имя отправителя выбирается ПО ОПЕРАТОРУ получателя, и пустая
* строка означает «своего имени у клиента для этой сети нет подпишись своим».
* Универсал же передавал имя КАК ЕСТЬ и ушёл бы с пустым отправителем.
*/
it('пустое имя заменяет своим согласованным', function () {
Http::fake(['smsc.ru/*' => Http::response(['id' => 777, 'cnt' => 1], 200)]);
$provider = new SmscSmsProvider('login', 'secret', ['*'], ['*' => 300], 'liderra.ru');
$provider->send(new SmsOutgoing(
phone: '79990000001',
body: 'Здравствуйте!',
senderName: '',
operator: 'МегаФон',
segments: 1,
));
Http::assertSent(fn ($request) => $request['sender'] === 'liderra.ru');
});
it('заданное имя клиента своим не подменяет', function () {
Http::fake(['smsc.ru/*' => Http::response(['id' => 778, 'cnt' => 1], 200)]);
$provider = new SmscSmsProvider('login', 'secret', ['*'], ['*' => 300], 'liderra.ru');
$provider->send(new SmsOutgoing(
phone: '79990000001',
body: 'Здравствуйте!',
senderName: 'mybrand.ru',
operator: 'МегаФон',
segments: 1,
));
Http::assertSent(fn ($request) => $request['sender'] === 'mybrand.ru');
});
it('успешная отправка возвращает id сообщения и цену по сегментам', function () {
Http::fake(['smsc.ru/*' => Http::response(['id' => 555, 'cnt' => 1], 200)]);