From 1dece3a2b42552c407bedf6ede33bb51986cd5ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 27 Jul 2026 18:22:26 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D1=81=D0=BC=D1=81-=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82):=20=D1=85=D0=B2=D0=BE=D1=81=D1=82=20=C2=AB?= =?UTF-8?q?=D0=9E=D1=82=D0=BA=D0=B0=D0=B7:=20liderra.ru/s/=E2=80=A6=C2=BB?= =?UTF-8?q?=20=D0=B2=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B5=20=E2=80=94=20?= =?UTF-8?q?=D1=86=D0=B5=D0=BD=D0=B0=20=D1=81=D1=87=D0=B8=D1=82=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D1=83=D0=B6=D0=B5=20=D1=81=20=D0=BD=D0=B8?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Галочка «добавить возможность отказа» в рассылке, по умолчанию включена (спека §9.2). Токен свой у каждого получателя, поэтому текст собирается на каждый номер отдельно в момент отправки. Длина хвоста постоянная — число кусков и цена считаются один раз, при создании рассылки, УЖЕ с хвостом: клиент видит ту длину, за которую заплатит. Заготовка хвоста живёт в одном месте (сервис ссылок отказа), длина берётся из неё же — число в код не вписано, разойтись расчёту и отправке нечем. Предпросмотр отдаёт второе число (сколько кусков было бы без хвоста), чтобы экран мог сказать прямо: хвост перевёл текст на второй кусок. Строки приёмочного листа 1.10-1.13 (серверная часть). Защита проверена вырезанием: считать цену без хвоста — 2 красных теста, не дописывать хвост при отправке — 1. ClientSms 146/146, приём лидов 17/17, phpstan по своим файлам чисто. --- .../Api/Client/ClientSmsController.php | 18 +- app/app/Jobs/SendClientSmsCampaignJob.php | 15 +- app/app/Models/ClientSmsCampaign.php | 2 + .../Services/ClientSms/ClientSmsPricing.php | 16 ++ .../ClientSmsUnsubscribeLinkService.php | 22 ++ ...th_optout_link_to_client_sms_campaigns.php | 33 +++ .../Feature/ClientSms/OptoutTailTest.php | 205 ++++++++++++++++++ app/tests/Feature/ClientSms/SendJobTest.php | 9 +- db/CHANGELOG_schema.md | 24 ++ 9 files changed, 339 insertions(+), 5 deletions(-) create mode 100644 app/database/migrations/2026_07_30_100300_add_with_optout_link_to_client_sms_campaigns.php create mode 100644 app/tests/Feature/ClientSms/OptoutTailTest.php diff --git a/app/app/Http/Controllers/Api/Client/ClientSmsController.php b/app/app/Http/Controllers/Api/Client/ClientSmsController.php index ea25efb5..3b3525e6 100644 --- a/app/app/Http/Controllers/Api/Client/ClientSmsController.php +++ b/app/app/Http/Controllers/Api/Client/ClientSmsController.php @@ -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, ]); } diff --git a/app/app/Jobs/SendClientSmsCampaignJob.php b/app/app/Jobs/SendClientSmsCampaignJob.php index f0f4ebe0..5a916617 100644 --- a/app/app/Jobs/SendClientSmsCampaignJob.php +++ b/app/app/Jobs/SendClientSmsCampaignJob.php @@ -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, diff --git a/app/app/Models/ClientSmsCampaign.php b/app/app/Models/ClientSmsCampaign.php index 9d462305..beaf6fec 100644 --- a/app/app/Models/ClientSmsCampaign.php +++ b/app/app/Models/ClientSmsCampaign.php @@ -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', diff --git a/app/app/Services/ClientSms/ClientSmsPricing.php b/app/app/Services/ClientSms/ClientSmsPricing.php index 94f287f5..7e580895 100644 --- a/app/app/Services/ClientSms/ClientSmsPricing.php +++ b/app/app/Services/ClientSms/ClientSmsPricing.php @@ -17,6 +17,22 @@ final class ClientSmsPricing return $this->calculator->segments($body); } + /** + * Сколько кусков займёт текст с учётом хвоста для отказа (строки листа 1.11–1.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 { diff --git a/app/app/Services/ClientSms/ClientSmsUnsubscribeLinkService.php b/app/app/Services/ClientSms/ClientSmsUnsubscribeLinkService.php index ee8f81db..e8c8602d 100644 --- a/app/app/Services/ClientSms/ClientSmsUnsubscribeLinkService.php +++ b/app/app/Services/ClientSms/ClientSmsUnsubscribeLinkService.php @@ -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() diff --git a/app/database/migrations/2026_07_30_100300_add_with_optout_link_to_client_sms_campaigns.php b/app/database/migrations/2026_07_30_100300_add_with_optout_link_to_client_sms_campaigns.php new file mode 100644 index 00000000..e1fcbb6b --- /dev/null +++ b/app/database/migrations/2026_07_30_100300_add_with_optout_link_to_client_sms_campaigns.php @@ -0,0 +1,33 @@ +boolean('with_optout_link')->default(true); + }); + } + + public function down(): void + { + Schema::table('client_sms_campaigns', function (Blueprint $table) { + $table->dropColumn('with_optout_link'); + }); + } +}; diff --git a/app/tests/Feature/ClientSms/OptoutTailTest.php b/app/tests/Feature/ClientSms/OptoutTailTest.php new file mode 100644 index 00000000..b8265981 --- /dev/null +++ b/app/tests/Feature/ClientSms/OptoutTailTest.php @@ -0,0 +1,205 @@ + 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 номер => текст */ + 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()); +}); diff --git a/app/tests/Feature/ClientSms/SendJobTest.php b/app/tests/Feature/ClientSms/SendJobTest.php index 85d7dbb7..368ce8fd 100644 --- a/app/tests/Feature/ClientSms/SendJobTest.php +++ b/app/tests/Feature/ClientSms/SendJobTest.php @@ -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); diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 3faab273..681c5eff 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -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` + право служебной роли на стоп-лист Две миграции: