feat телеграм-робот: таблица заданий роботу с построчной защитой
Номера телефонов в задание не кладём — только текст, ссылка и смета. После выката на бой перезапустить db/03_service_bypass_policies.sql. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
});
|
||||
@@ -2515,3 +2515,5 @@ otkaz
|
||||
otkaza
|
||||
razvernuto
|
||||
zakryt
|
||||
гардом
|
||||
тенантный
|
||||
|
||||
@@ -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 вон)
|
||||
|
||||
Модуль ресейлит МТС Маркетолог «показы рекламы по своей базе» — там НЕТ имени отправителя, доставки
|
||||
|
||||
Reference in New Issue
Block a user