feat телеграм-робот: таблица заданий роботу с построчной защитой

Номера телефонов в задание не кладём — только текст, ссылка и смета.
После выката на бой перезапустить db/03_service_bypass_policies.sql.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-31 06:39:24 +03:00
parent 5f26c22c33
commit 32c8fa2881
4 changed files with 160 additions and 0 deletions
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Очередь заданий телеграм-роботу кабинета МТС.
*
* У МТС нет программного интерфейса кампанию доводит браузер-робот, и живёт он НЕ там,
* где очередь портала (МТС не пускает адреса дата-центров). Поэтому портал не запускает
* робота, а кладёт сюда задание; робот сам приходит за ним по служебному каналу.
*
* 🔴 ПОСЛЕ ВЫКАТА НА БОЙ перезапустить `db/03_service_bypass_policies.sql`. На боевом
* кластере служебные роли НЕ BYPASSRLS кросс-тенантный доступ им даёт именно этот файл.
* Без его повторного прогона канал робота увидит ноль строк молча, без единой ошибки.
* Перечислять таблицу в том файле НЕ надо: он таблично-агностичен идёт циклом по всем
* таблицам с включённой построчной защитой и новую подхватит сам. Нужен именно ПРОГОН,
* миграциями он не запускается.
*
* 🔴 Номера телефонов в `payload` НЕ кладём (ПДн, 152-ФЗ): payload уходит в журналы.
* Номера робот забирает отдельным запросом, пока задание в работе.
*
* CHANGELOG: v9.28.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('client_tg_robot_jobs', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('campaign_id')->constrained('client_tg_campaigns')->cascadeOnDelete();
$table->string('mode', 16); // draft | live
$table->jsonb('payload'); // текст, ссылка, смета — БЕЗ номеров
$table->string('status', 16)->default('queued'); // queued → taken → done | failed
$table->unsignedSmallInteger('attempts')->default(0);
$table->jsonb('result')->nullable(); // сырой ответ робота
$table->string('failure_reason', 1024)->nullable();
$table->timestamp('taken_at')->nullable();
$table->timestamp('finished_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'campaign_id']);
$table->index('status');
});
DB::statement('ALTER TABLE client_tg_robot_jobs ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE client_tg_robot_jobs FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_tg_robot_jobs');
DB::statement("CREATE POLICY tenant_isolation ON client_tg_robot_jobs USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
// Гранты — под гардом на существование роли: на dev/тестах ходит суперпользователь
// postgres, ролей там нет, и голый GRANT уронил бы миграцию.
DB::statement(<<<'SQL'
DO $$
BEGIN
-- Портал ставит задания при запуске кампании.
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT SELECT, INSERT, UPDATE ON client_tg_robot_jobs TO crm_app_user;
END IF;
-- Канал робота работает под crm_admin_user (посредник admin-db).
-- Заданий робот не создаёт только берёт и отчитывается: INSERT ему не даём.
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT, UPDATE ON client_tg_robot_jobs TO crm_admin_user;
END IF;
END
$$;
SQL);
// Нумератор — отдельный объект со своими правами. Без USAGE на нём INSERT падает
// на бою с «permission denied for sequence», а на dev дырка невидима.
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
IF EXISTS (
SELECT 1 FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'client_tg_robot_jobs_id_seq' AND c.relkind = 'S' AND n.nspname = 'public'
) THEN
GRANT USAGE, SELECT ON SEQUENCE public.client_tg_robot_jobs_id_seq TO crm_app_user;
END IF;
END IF;
END
$$;
SQL);
}
public function down(): void
{
Schema::dropIfExists('client_tg_robot_jobs');
}
};
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
it('таблица заданий роботу существует с нужными столбцами', function () {
expect(Schema::hasTable('client_tg_robot_jobs'))->toBeTrue();
foreach ([
'id', 'tenant_id', 'campaign_id', 'mode', 'payload', 'status',
'attempts', 'result', 'failure_reason', 'taken_at', 'finished_at',
] as $column) {
expect(Schema::hasColumn('client_tg_robot_jobs', $column))->toBeTrue("нет столбца {$column}");
}
});
it('на таблице включена построчная защита с политикой по тенанту', function () {
$rls = DB::selectOne("SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = 'client_tg_robot_jobs'");
expect($rls->relrowsecurity)->toBeTrue();
expect($rls->relforcerowsecurity)->toBeTrue();
$policy = DB::selectOne("SELECT polname FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid WHERE c.relname = 'client_tg_robot_jobs'");
expect($policy->polname)->toBe('tenant_isolation');
});
+2
View File
@@ -2515,3 +2515,5 @@ otkaz
otkaza
razvernuto
zakryt
гардом
тенантный
+35
View File
@@ -10,6 +10,41 @@
> параллельно с боевым main. Их прежние номера (v8.86–v8.95) **столкнулись** с боевыми (рекламный
> кошелёк и Яндекс-канал), поэтому при сведении они перенумерованы. Содержание не менялось.
## v9.28 (2026-07-31) — Telegram-модуль: очередь заданий роботу кабинета МТС
Портал перестаёт запускать браузер-робота у себя на машине и начинает класть ему задание в таблицу —
робот приходит за ним сам по служебному каналу. Причина: у МТС нет программного интерфейса, кампанию
доводит браузер, а он физически не может жить там же, где очередь портала (МТС отбивает адреса
дата-центров, замер 30.07.2026, 4 прогона). Схема списана с робота креативов Яндекса (`ad_creative_jobs`).
- **Новая таблица** `client_tg_robot_jobs` (миграция
`app/database/migrations/2026_07_31_100000_create_client_tg_robot_jobs.php`):
- `id`, `tenant_id``tenants` (cascade), `campaign_id``client_tg_campaigns` (cascade);
- `mode` (`draft` | `live`), `payload` jsonb — состав объявления **без номеров телефонов**;
- `status` (`queued``taken``done` | `failed`), `attempts`, `result` jsonb, `failure_reason`,
`taken_at`, `finished_at`, `timestamps`;
- индексы: `(tenant_id, campaign_id)` и `status`.
- **RLS:** `ENABLE` + `FORCE ROW LEVEL SECURITY`, политика `tenant_isolation` по
`current_setting('app.current_tenant_id')` — как у остальных `client_tg_*`.
- **GRANT:** `crm_app_user` — SELECT/INSERT/UPDATE (портал ставит задания) плюс `USAGE, SELECT` на
нумератор `client_tg_robot_jobs_id_seq`; `crm_admin_user` — SELECT/UPDATE (канал робота берёт и
отчитывается, заданий не создаёт, поэтому INSERT ему не даётся). Оба GRANT под гардом на
существование роли: на dev/тестах ходит суперпользователь `postgres`, ролей там нет.
🔴 **При выкате на бой ПЕРЕзапустить `db/03_service_bypass_policies.sql`.** На боевом кластере служебные
роли НЕ BYPASSRLS — кросс-тенантный доступ им даёт именно этот файл. Без повторного прогона канал робота
увидит в новой таблице ноль строк молча, без единой ошибки. Класс ошибки уже ловили 25.07.2026.
🔴 **ПДн:** номера телефонов в `payload` не кладутся — `payload` попадает в журналы. Номера робот
забирает отдельным запросом `/api/tg-robot/jobs/{id}/phones`, только пока задание в работе, без кеша.
**rls-reviewer:** прогнан 31.07.2026 — по самой таблице чисто (RLS, политика, гранты, нумератор
совпадают с образцом `ad_creative_jobs`). Дописывать таблицу в `db/03_service_bypass_policies.sql` не
надо: файл идёт циклом по всем таблицам с построчной защитой и подхватит её сам — нужен именно повторный
ПРОГОН файла на бою. 🔴 Отдельная находка ревьюера, к этой таблице не относящаяся, но на том же выкате:
у `crm_admin_user` на `client_tg_campaigns` был только SELECT, а канал робота пишет туда итог — закрыто
миграцией `2026_07_31_100100_grant_admin_update_client_tg_campaigns.php` (см. v9.29).
## v9.27 (2026-07-28) — Telegram-модуль: чистка СМС-наследия (дроп senders/settings/messages/templates + tg_name_fee вон)
Модуль ресейлит МТС Маркетолог «показы рекламы по своей базе» — там НЕТ имени отправителя, доставки