feat(смс-клиент): хвост «Отказ: liderra.ru/s/…» в тексте — цена считается уже с ним

Галочка «добавить возможность отказа» в рассылке, по умолчанию включена (спека §9.2).
Токен свой у каждого получателя, поэтому текст собирается на каждый номер отдельно
в момент отправки. Длина хвоста постоянная — число кусков и цена считаются один раз,
при создании рассылки, УЖЕ с хвостом: клиент видит ту длину, за которую заплатит.

Заготовка хвоста живёт в одном месте (сервис ссылок отказа), длина берётся из неё же —
число в код не вписано, разойтись расчёту и отправке нечем. Предпросмотр отдаёт второе
число (сколько кусков было бы без хвоста), чтобы экран мог сказать прямо: хвост перевёл
текст на второй кусок.

Строки приёмочного листа 1.10-1.13 (серверная часть). Защита проверена вырезанием:
считать цену без хвоста — 2 красных теста, не дописывать хвост при отправке — 1.

ClientSms 146/146, приём лидов 17/17, phpstan по своим файлам чисто.
This commit is contained in:
Дмитрий
2026-07-27 18:22:26 +03:00
parent 7aa3083399
commit 1dece3a2b4
9 changed files with 339 additions and 5 deletions
@@ -87,12 +87,18 @@ class ClientSmsController extends Controller
$data = $this->validatePayload($request);
$plan = $this->buildPlan($tenantId, $data);
$segments = $this->pricing->segments($data['body']);
$withTail = $data['with_optout_link'] ?? true;
$segments = $this->pricing->segmentsWithTail($data['body'], $withTail);
$sendable = count($plan->sendable);
$totalSms = $sendable * $segments;
return response()->json([
'segments' => $segments,
// Второе число — чтобы экран мог сказать прямо: «хвост перевёл текст на
// второй кусок, цена вдвое» (строка листа 1.12). Без него отличить это
// от «текст и так был длинный» невозможно.
'segments_without_tail' => $this->pricing->segments($data['body']),
'with_optout_link' => $withTail,
'sendable_count' => $sendable,
'skipped' => $this->aggregateSkipped($plan),
'estimated_cost_rub' => $this->pricing->estimateRub($sendable, $segments),
@@ -111,8 +117,12 @@ class ClientSmsController extends Controller
$body = $data['body'];
$audienceDays = $data['audience_days'] ?? null;
// Галочка отказа по умолчанию включена (спека §9.2): её отсутствие — риск
// юридический, а её наличие — вопрос цены, и цену клиент видит до отправки.
$withTail = $data['with_optout_link'] ?? true;
$plan = $this->buildPlan($tenantId, $data);
$segments = $this->pricing->segments($body);
$segments = $this->pricing->segmentsWithTail($body, $withTail);
$sendable = count($plan->sendable);
$totalSms = $sendable * $segments;
$price = $this->pricing->pricePerSmsRub($totalSms);
@@ -120,7 +130,7 @@ class ClientSmsController extends Controller
try {
$campaign = DB::transaction(function () use (
$request, $tenantId, $data, $source, $body, $audienceDays,
$request, $tenantId, $data, $source, $body, $audienceDays, $withTail,
$segments, $sendable, $totalSms, $price, $estimated
): ClientSmsCampaign {
$campaign = ClientSmsCampaign::create([
@@ -131,6 +141,7 @@ class ClientSmsController extends Controller
'source' => $source,
'audience_days' => $source === ClientSmsCampaign::SOURCE_DEALS ? $audienceDays : null,
'status' => ClientSmsCampaign::STATUS_QUEUED,
'with_optout_link' => $withTail,
'segments' => $segments,
'planned_count' => $sendable,
'total_sms' => $totalSms,
@@ -455,6 +466,7 @@ class ClientSmsController extends Controller
'audience_days' => 'required_if:source,deals|nullable|integer|min:1',
'phones' => 'array',
'phones.*' => 'string|size:11',
'with_optout_link' => 'boolean',
...$extra,
]);
}
+14 -1
View File
@@ -11,6 +11,7 @@ use App\Services\ClientSms\ClientSmsAudienceBuilder;
use App\Services\ClientSms\ClientSmsPlan;
use App\Services\ClientSms\ClientSmsPricing;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\ClientSms\ClientSmsUnsubscribeLinkService;
use App\Services\Sms\SmsOutgoing;
use App\Services\Sms\SmsSendException;
use Illuminate\Bus\Queueable;
@@ -62,6 +63,7 @@ class SendClientSmsCampaignJob implements ShouldQueue
ClientSmsRecipientSelector $selector,
ClientSmsPricing $pricing,
AdWalletService $wallet,
ClientSmsUnsubscribeLinkService $links,
): void {
$sandbox = (bool) config('services.sms.sandbox');
@@ -132,10 +134,21 @@ class SendClientSmsCampaignJob implements ShouldQueue
$provider = $row['provider'];
// Текст собирается НА КАЖДЫЙ НОМЕР: в хвосте личная ссылка получателя
// (спека §9.2). Длина хвоста одинакова для всех, поэтому снимок цены и
// число кусков, посчитанные при создании рассылки, остаются верными.
// Токен пишется в БД — только под tenant-контекстом, иначе RLS вернула бы
// ноль и на каждый номер плодилась бы новая ссылка.
$body = $campaign->body;
if ($campaign->with_optout_link) {
$body .= $this->tenantTx(fn () => $links->tailFor($this->tenantId, $row['phone']));
}
try {
$result = $provider->send(new SmsOutgoing(
phone: $row['phone'],
body: $campaign->body,
body: $body,
senderName: $campaign->sender_name,
operator: $row['operator'],
segments: $segments,
+2
View File
@@ -40,6 +40,7 @@ class ClientSmsCampaign extends Model
'source',
'audience_days',
'status',
'with_optout_link',
'segments',
'planned_count',
'sent_count',
@@ -55,6 +56,7 @@ class ClientSmsCampaign extends Model
return [
'tenant_id' => 'integer',
'audience_days' => 'integer',
'with_optout_link' => 'boolean',
'segments' => 'integer',
'planned_count' => 'integer',
'sent_count' => 'integer',
@@ -17,6 +17,22 @@ final class ClientSmsPricing
return $this->calculator->segments($body);
}
/**
* Сколько кусков займёт текст с учётом хвоста для отказа (строки листа 1.111.13).
*
* Текст у каждого получателя свой (в хвосте его личный токен), но длина хвоста
* одинакова для всех поэтому цена считается один раз, здесь. Заполнитель из
* латинских «x» правомерен: куски считаются по числу символов, а не по алфавиту.
*/
public function segmentsWithTail(string $body, bool $withTail): int
{
if (! $withTail) {
return $this->segments($body);
}
return $this->segments($body.str_repeat('x', ClientSmsUnsubscribeLinkService::tailLength()));
}
/** Цена за 1 СМС для объёма V = строка с наибольшим min_qty <= V (V не меньше 1). */
public function pricePerSmsRub(int $volume): string
{
@@ -19,6 +19,28 @@ final class ClientSmsUnsubscribeLinkService
private const LENGTH = 12;
/**
* Заготовка хвоста для отказа (спека §9.2). Живёт ЗДЕСЬ в единственном экземпляре:
* этой же строкой считается цена и собирается сообщение. Написать её дважды
* значит однажды разойтись, и тогда клиент платит за одну длину, а уходит другая.
*
* Домен вписан строкой намеренно, а не берётся из настроек: адрес стенда длиннее
* боевого, и цена на стенде перестала бы совпадать с ценой в бою (В-24).
*/
public const TAIL_PREFIX = ' Отказ: liderra.ru/s/';
/** Длина хвоста в символах — постоянная, потому что токен всегда одной длины. */
public static function tailLength(): int
{
return mb_strlen(self::TAIL_PREFIX) + self::LENGTH;
}
/** Готовый хвост для конкретного номера — то, что реально дописывается к тексту. */
public function tailFor(int $tenantId, string $phone): string
{
return self::TAIL_PREFIX.$this->tokenFor($tenantId, $phone);
}
public function tokenFor(int $tenantId, string $phone): string
{
$existing = ClientSmsUnsubscribeLink::query()
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Строки листа 1.101.13: дописывать ли к тексту хвост «Отказ: liderra.ru/s/…».
*
* DEFAULT true осознанно (спека §9.2): юридический риск от рассылки без возможности
* отказаться выше, чем недовольство ценой. Снять галочку клиент может, но решение
* это его и принимается явно.
*
* Колонка влияет на ДЕНЬГИ: с хвостом текст длиннее, кусков может стать больше,
* цена выше. Поэтому флаг фиксируется в самой кампании, а не читается заново при
* отправке: снимок цены и снимок текста должны описывать одно и то же сообщение.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('client_sms_campaigns', function (Blueprint $table) {
$table->boolean('with_optout_link')->default(true);
});
}
public function down(): void
{
Schema::table('client_sms_campaigns', function (Blueprint $table) {
$table->dropColumn('with_optout_link');
});
}
};
@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
use App\Jobs\SendClientSmsCampaignJob;
use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsContact;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\ClientSmsAudienceBuilder;
use App\Services\ClientSms\ClientSmsPricing;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\ClientSms\ClientSmsUnsubscribeLinkService;
use App\Services\Sms\OperatorNormalizer;
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;
/**
* Строки листа 1.101.13: хвост «Отказ: liderra.ru/s/…» в тексте рассылки.
*
* Здесь сходятся текст и деньги. Токен свой у каждого номера, поэтому текст
* собирается на каждый номер отдельно но длина хвоста постоянная, и цена
* считается один раз при создании, УЖЕ с хвостом. Если расчёт и сборка разойдутся
* хоть на символ, клиент заплатит за одну длину, а уйдёт другая поэтому длина
* проверяется против настоящей строки, а не против числа в коде (В-21).
*
* Синтетические номера 7999 реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
config(['services.sms.sandbox' => true]);
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** Провайдер-соглядатай: запоминает тексты, которые реально ушли бы в линию. */
function tailSpyProvider(): SmsProvider
{
return new class implements SmsProvider
{
/** @var array<string, string> номер => текст */
public array $bodies = [];
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->bodies[$message->phone] = $message->body;
return new SmsSendResult('MSG-TAIL-1', $message->segments, 0, CarbonImmutable::now());
}
};
}
/** Кампания «по своей базе» в очереди — как её создаёт контроллер. */
function tailCampaign(int $tenantId, string $body, bool $withTail): ClientSmsCampaign
{
return ClientSmsCampaign::create([
'tenant_id' => $tenantId,
'title' => 'Рассылка',
'body' => $body,
'sender_name' => 'liderra.ru',
'source' => ClientSmsCampaign::SOURCE_BASE,
'status' => ClientSmsCampaign::STATUS_QUEUED,
'with_optout_link' => $withTail,
'segments' => 1,
'planned_count' => 2,
'total_sms' => 2,
'price_rub_per_sms' => '8.50',
'estimated_cost_rub' => '0.00',
'created_by' => null,
]);
}
it('предпросмотр считает длину ВМЕСТЕ с хвостом, а не без него', function () {
$body = str_repeat('а', 60);
$with = $this->postJson('/api/sms/preview', [
'source' => 'manual', 'phones' => ['79990000001'],
'body' => $body, 'with_optout_link' => true,
])->assertOk()->json();
$without = $this->postJson('/api/sms/preview', [
'source' => 'manual', 'phones' => ['79990000001'],
'body' => $body, 'with_optout_link' => false,
])->assertOk()->json();
expect($with['segments'])->toBeGreaterThan($without['segments'])
->and((float) $with['estimated_cost_rub'])->toBeGreaterThan((float) $without['estimated_cost_rub']);
});
it('предпросмотр отдельно говорит, сколько кусков было бы без хвоста (строка 1.12)', function () {
$res = $this->postJson('/api/sms/preview', [
'source' => 'manual', 'phones' => ['79990000001'],
'body' => str_repeat('а', 60), 'with_optout_link' => true,
])->assertOk();
// 60 символов — один кусок; 60 + хвост — уже два. Экран обязан сказать это прямо.
$res->assertJsonPath('segments', 2)
->assertJsonPath('segments_without_tail', 1);
});
it('галочка отказа включена по умолчанию — поле в запросе не пришло', function () {
$this->postJson('/api/sms/campaigns', [
'title' => 'Тест', 'source' => 'manual',
'phones' => ['79990000001'], 'body' => str_repeat('а', 60),
])->assertCreated();
$campaign = ClientSmsCampaign::latest('id')->first();
expect($campaign->with_optout_link)->toBeTrue()
->and($campaign->segments)->toBe(2); // цена зафиксирована уже с хвостом
});
it('клиент снял галочку — хвоста нет, кусков меньше и цена ниже', function () {
$body = str_repeat('а', 60);
$this->postJson('/api/sms/campaigns', [
'title' => 'С хвостом', 'source' => 'manual',
'phones' => ['79990000001'], 'body' => $body, 'with_optout_link' => true,
])->assertCreated();
$withTail = ClientSmsCampaign::latest('id')->first();
$this->postJson('/api/sms/campaigns', [
'title' => 'Без хвоста', 'source' => 'manual',
'phones' => ['79990000001'], 'body' => $body, 'with_optout_link' => false,
])->assertCreated();
$without = ClientSmsCampaign::latest('id')->first();
expect($without->with_optout_link)->toBeFalse()
->and($without->segments)->toBe(1)
->and($withTail->segments)->toBe(2)
// Цена — не выдуманное число, а строго меньше, чем за ту же рассылку с хвостом.
->and((float) $without->estimated_cost_rub)
->toBeLessThan((float) $withTail->estimated_cost_rub);
});
it('в линию уходит текст с личной ссылкой ИМЕННО этого номера', function () {
ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС']);
ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС']);
$campaign = tailCampaign($this->tenant->id, 'Здравствуйте!', true);
$spy = tailSpyProvider();
(new SendClientSmsCampaignJob($campaign->id, $this->tenant->id))->handle(
app(ClientSmsAudienceBuilder::class),
new ClientSmsRecipientSelector(new SmsRouter([$spy]), new OperatorNormalizer),
app(ClientSmsPricing::class),
app(AdWalletService::class),
app(ClientSmsUnsubscribeLinkService::class),
);
$links = app(ClientSmsUnsubscribeLinkService::class);
$first = $links->tokenFor($this->tenant->id, '79990000001');
$second = $links->tokenFor($this->tenant->id, '79990000002');
expect($first)->not->toBe($second)
->and($spy->bodies['79990000001'])->toBe('Здравствуйте! Отказ: liderra.ru/s/'.$first)
->and($spy->bodies['79990000002'])->toBe('Здравствуйте! Отказ: liderra.ru/s/'.$second);
});
it('галочка снята — в линию уходит ровно исходный текст, без хвоста', function () {
ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС']);
$campaign = tailCampaign($this->tenant->id, 'Здравствуйте!', false);
$spy = tailSpyProvider();
(new SendClientSmsCampaignJob($campaign->id, $this->tenant->id))->handle(
app(ClientSmsAudienceBuilder::class),
new ClientSmsRecipientSelector(new SmsRouter([$spy]), new OperatorNormalizer),
app(ClientSmsPricing::class),
app(AdWalletService::class),
app(ClientSmsUnsubscribeLinkService::class),
);
expect($spy->bodies['79990000001'])->toBe('Здравствуйте!');
});
it('длина хвоста в расчёте цены равна длине настоящего хвоста (В-21)', function () {
$links = app(ClientSmsUnsubscribeLinkService::class);
$real = $links->tailFor($this->tenant->id, '79990000001');
expect(mb_strlen($real))->toBe(ClientSmsUnsubscribeLinkService::tailLength());
});
+8 -1
View File
@@ -13,6 +13,7 @@ use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\ClientSmsAudienceBuilder;
use App\Services\ClientSms\ClientSmsPricing;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\ClientSms\ClientSmsUnsubscribeLinkService;
use App\Services\Sms\OperatorNormalizer;
use App\Services\Sms\Providers\MtsSmsProvider;
use App\Services\Sms\SmsOutgoing;
@@ -96,6 +97,7 @@ it('sandbox: отправляет через заглушку, стоп-лист
app(ClientSmsRecipientSelector::class),
app(ClientSmsPricing::class),
app(AdWalletService::class),
app(ClientSmsUnsubscribeLinkService::class),
);
$campaign->refresh();
@@ -141,6 +143,7 @@ it('реальный режим: списывает по факту, снима
realMtsSelector(),
app(ClientSmsPricing::class),
$wallet,
app(ClientSmsUnsubscribeLinkService::class),
);
$campaign->refresh();
@@ -178,6 +181,7 @@ it('идемпотентность: повторный запуск не пло
app(ClientSmsRecipientSelector::class),
app(ClientSmsPricing::class),
app(AdWalletService::class),
app(ClientSmsUnsubscribeLinkService::class),
);
$run();
@@ -217,6 +221,7 @@ it('идемпотентность реального режима: второй
realMtsSelector(),
app(ClientSmsPricing::class),
$wallet,
app(ClientSmsUnsubscribeLinkService::class),
);
$run();
@@ -280,7 +285,7 @@ it('факт списывается ПОЛНОСТЬЮ при падении п
try {
(new SendClientSmsCampaignJob($campaign->id, $tenant->id))->handle(
app(ClientSmsAudienceBuilder::class), $flakySelector, app(ClientSmsPricing::class), $wallet,
app(ClientSmsAudienceBuilder::class), $flakySelector, app(ClientSmsPricing::class), $wallet, app(ClientSmsUnsubscribeLinkService::class),
);
} catch (RuntimeException) {
// ожидаемо: заход упал посреди рассылки
@@ -289,6 +294,7 @@ it('факт списывается ПОЛНОСТЬЮ при падении п
// Заход 2 (ретрай): рабочий провайдер — дошлёт остаток.
(new SendClientSmsCampaignJob($campaign->id, $tenant->id))->handle(
app(ClientSmsAudienceBuilder::class), realMtsSelector(), app(ClientSmsPricing::class), $wallet,
app(ClientSmsUnsubscribeLinkService::class),
);
// Оба номера отправлены — и ОБА оплачены (2 × 8.50 = 17.00), а не только один.
@@ -318,6 +324,7 @@ it('нет кампании: джоб тихо выходит без исклю
app(ClientSmsRecipientSelector::class),
app(ClientSmsPricing::class),
app(AdWalletService::class),
app(ClientSmsUnsubscribeLinkService::class),
);
expect(ClientSmsMessage::count())->toBe(0);
+24
View File
@@ -8,6 +8,30 @@
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
> поэтому при сведении они перенумерованы. Содержание не менялось.
## v9.03 (2026-07-30) — Клиентская СМС, Этап 1: галочка «добавить возможность отказа» в рассылке
`app/database/migrations/2026_07_30_100300_add_with_optout_link_to_client_sms_campaigns.php`
— в `client_sms_campaigns` добавлена колонка:
- `with_optout_link` boolean **NOT NULL DEFAULT true** — дописывать ли к тексту
хвост ` Отказ: liderra.ru/s/{token}` (спека §9.2, строки приёмочного листа 1.10–1.13).
**Почему DEFAULT true.** Рассылка без возможности отказаться — юридический риск;
он выше, чем недовольство ценой. Снять галочку клиент может, но это его явное
решение, а не умолчание системы. Существующих рассылок на момент миграции нет
(модуль не на проде), поэтому значение по умолчанию никого не перекрашивает.
**Почему колонка, а не вычисление при отправке.** Хвост удлиняет текст и может
перевести его на второй кусок — то есть удвоить цену. Цена фиксируется снимком
при создании рассылки (`segments`, `price_rub_per_sms`, `estimated_cost_rub`),
и флаг обязан лежать рядом с этим снимком: снимок цены и снимок текста должны
описывать одно и то же сообщение. Иначе изменение настройки задним числом
рассинхронизировало бы деньги и текст.
**RLS не трогается** — таблица уже под `tenant_isolation`, новых прав не нужно.
---
## v9.02 (2026-07-30) — Клиентская СМС, Этап 1: короткие ссылки отказа `client_sms_unsubscribe_links` + право служебной роли на стоп-лист
Две миграции: