feat(смс-клиент): судьба сообщения отдельной графой + право служебной роли править журнал

Этап 5, Task 2. Схема v9.23 и v9.24.

Графа судьбы (миграция 101100): семь колонок у client_sms_messages —
delivery_status, delivered_at, delivery_checked_at, delivery_raw,
provider_cost, provider_parts, refunded_at, плюс индекс «кого спрашивать дальше».

Почему графа, а не переписывание status: по status считаются ДЕНЬГИ и месячный
объём клиента (ClientSmsVolumeCounter берёт ровно sent), его же складывает сторож
зависших и разбирают два словаря подписей на экране. Заменив sent на delivered,
мы обнулили бы клиенту накопление за месяц и удешевили бы цену задним числом.
Значит status остаётся фактом ПЕРЕДАЧИ, а судьба живёт рядом (В-202).

Цена оператора (provider_cost) хранится КАК ЕСТЬ: единицы поля cost у МТС
неизвестны (В-211), в рубли не переводится и ни во что не подставляется.

Право служебной роли (миграция 101200): GRANT UPDATE на client_sms_messages.
Команда опроса судьбы кросс-клиентская, ходит служебным соединением и ПРАВИТ
существующую строку — новых не пишет намеренно (В-204: новые строки делали бы
зависшую рассылку «живой» для сторожа, и деньги остались бы замороженными).
Без права на бою команда падала бы каждые десять минут; локально не видно
никогда — dev и тесты ходят суперпользователем (В-126/В-127).

Проверено:
- сторож MigrationGrantsTest расширен и проверен ВЫРЕЗОМ: гашение GRANT в
  миграции красит его;
- изоляция цела — запросом к базе после наката: RLS true/true, политика
  tenant_isolation на месте, права ролей ровно ожидаемые, все семь колонок есть;
- весь модуль 327/327 (было 320, +7 читателя судьбы), 13 пачек, все с первой
  попытки; phpstan ровно 2 чужие давние, pint чисто.
This commit is contained in:
Дмитрий
2026-07-31 06:04:56 +03:00
parent 8177cac39b
commit 36c8ced41e
6 changed files with 223 additions and 3 deletions
+19
View File
@@ -74,6 +74,15 @@ class ClientSmsMessage extends Model
'segments',
'provider_message_id',
'error',
// Судьба сообщения (строка листа 5.1, Этап 5). 🔴 Не путать со `status`:
// тот означает «мы отдали оператору», и по нему считаются деньги (В-202).
'delivery_status',
'delivered_at',
'delivery_checked_at',
'delivery_raw',
'provider_cost',
'provider_parts',
'refunded_at',
];
protected function casts(): array
@@ -86,6 +95,16 @@ class ClientSmsMessage extends Model
// Сколько СМС в этом сообщении: длинное письмо — это два, и платит клиент
// за два. Пусто у старых записей = одно (В-108, В-109).
'segments' => 'integer',
// Судьба сообщения (строка листа 5.1). Живёт РЯДОМ со `status`, а не
// вместо него: `status` — это «мы отдали оператору» и по нему считаются
// деньги и месячный объём, судьба — «дошло ли до человека» (В-202).
'delivered_at' => 'immutable_datetime',
'delivery_checked_at' => 'immutable_datetime',
'refunded_at' => 'immutable_datetime',
'provider_parts' => 'integer',
// 🔴 Число оператора КАК ЕСТЬ, единицы неизвестны (В-211) — в рубли не
// переводится и ни во что не подставляется до живой сверки.
'provider_cost' => 'decimal:4',
];
}
@@ -0,0 +1,68 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Судьба сообщения ОТДЕЛЬНОЙ графой рядом со статусом отправки (строка листа 5.1,
* решение В-202). `status` не трогаем: по нему считаются ДЕНЬГИ и месячный объём
* клиента (`ClientSmsVolumeCounter` берёт ровно `sent`), его же складывает сторож
* зависших и разбирают два словаря подписей на экране. Заменив `sent` на
* `delivered`, мы обнулили бы клиенту накопление за месяц и удешевили бы ему цену
* задним числом.
*
* Новые КОЛОНКИ прав не требуют наследуют привилегии таблицы.
*/
return new class extends Migration
{
public function up(): void
{
// Защита от повторного запуска (журнал В-73). 🔴 Оговорка семейства (В-131):
// в SQL, который печатает `migrate --pretend`, этой защиты НЕТ — при ручном
// накате на бою прерванный накат ПРОДОЛЖАТЬ с неисполненного куска, а не
// начинать сначала; «колонка уже существует» значит, что кусок уже прошёл.
if (Schema::hasColumn('client_sms_messages', 'delivery_status')) {
return;
}
Schema::table('client_sms_messages', function (Blueprint $table) {
// sending | delivered | not_delivered | not_sent (SmsDeliveryState).
$table->string('delivery_status', 16)->nullable();
// Когда дошло до человека (userDeliveryDate в ответе МТС).
$table->timestampTz('delivered_at')->nullable();
// Когда мы последний раз спрашивали судьбу — чтобы не долбить оператора.
$table->timestampTz('delivery_checked_at')->nullable();
// Слово оператора, которого мы не поняли, — для разбора, не для логики.
$table->string('delivery_raw', 32)->nullable();
// 🔴 Число из ответа МТС КАК ЕСТЬ. Единицы поля `cost` неизвестны (журнал
// В-211 записал только сам факт его наличия), перевод в рубли появится
// после живой сверки с известной ценой. Ни во что не подставлять.
$table->decimal('provider_cost', 14, 4)->nullable();
// На сколько частей разбил сообщение сам оператор — сверяем со своим счётом.
$table->smallInteger('provider_parts')->nullable();
// Когда вернули клиенту деньги за это сообщение (решение владельца В-198).
// Вторая опора идемпотентности рядом с ключом события в кошельке.
$table->timestampTz('refunded_at')->nullable();
// Кого спрашивать дальше: незакрытые отправленные, свежее трёх суток.
$table->index(['delivery_status', 'created_at'], 'client_sms_messages_delivery_idx');
});
}
public function down(): void
{
Schema::table('client_sms_messages', function (Blueprint $table) {
$table->dropIndex('client_sms_messages_delivery_idx');
$table->dropColumn([
'delivery_status',
'delivered_at',
'delivery_checked_at',
'delivery_raw',
'provider_cost',
'provider_parts',
'refunded_at',
]);
});
}
};
@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Команда опроса судьбы (`client-sms:poll-delivery`) ходит СЛУЖЕБНЫМ соединением
* она кросс-клиентская и ПРАВИТ строки журнала сообщений: отчёт о доставке меняет
* существующую запись, новых не пишет (решение В-204). Права `UPDATE` у роли не
* было: до сих пор служебная роль журнал только читала.
*
* 🔴 Без этого права на бою команда ПАДАЕТ «нет доступа к таблице» каждые десять
* минут. Локально не видно НИКОГДА: dev и тесты ходят суперпользователем
* (уроки В-126, В-127).
* 🔴 Право только ОДНА из двух опор, и у них РАЗНАЯ цена отказа (В-181): нет
* права падение, видно; нет разрешающей политики `srv_bypass` из
* `db/03_service_bypass_policies.sql` команда «успешно» правит НОЛЬ строк, тихо.
* Поэтому перезапуск `db/03` при выкате несущая опора, а не подстраховка.
*
* Гард на существование роли обязателен (урок В-36): опечатка в имени роли ВНУТРИ
* гарда ошибки не даёт.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT UPDATE ON client_sms_messages TO crm_supplier_worker;
END IF;
END $$;
SQL);
}
public function down(): void
{
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
REVOKE UPDATE ON client_sms_messages FROM crm_supplier_worker;
END IF;
END $$;
SQL);
}
};
@@ -84,7 +84,13 @@ const CLIENT_SMS_SERVICE_GRANTS = [
// Проверено живым прогоном: без этого права команда каждую ночь ПАДАЕТ с «нет
// доступа к таблице», и персональные данные остаются лежать.
'client_sms_campaign_phones' => ['SELECT', 'UPDATE', 'DELETE'],
'client_sms_messages' => ['SELECT'],
// UPDATE добавлен в Этапе 5 (строки листа 5.1–5.2, миграция `2026_08_01_101200`).
// Команда `client-sms:poll-delivery` спрашивает у оператора судьбу сообщений и
// ПРАВИТ существующую строку журнала — новых не пишет намеренно (В-204: новые
// строки делали бы зависшую рассылку «живой» для сторожа, и деньги остались бы
// замороженными). Без этого права на бою команда падает «нет доступа»; локально
// не видно НИКОГДА — dev и тесты ходят суперпользователем (класс В-126/В-127).
'client_sms_messages' => ['SELECT', 'UPDATE'],
],
];
+4
View File
@@ -2440,3 +2440,7 @@ kanal
Директа
яндексовы
поллинг
админской
неушедшие
тенантного
энумератор
+77 -2
View File
@@ -8,6 +8,81 @@
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
> поэтому при сведении они перенумерованы. Содержание не менялось.
## v9.24 (2026-08-01) — Клиентская СМС, Этап 5: право ПРАВИТЬ журнал сообщений служебной ролью
**Миграция:** `2026_08_01_101200_grant_supplier_worker_update_messages.php`. Ни таблиц, ни колонок не заводит — только одно право.
| Роль | Таблица | Право | Зачем |
|---|---|---|---|
| `crm_supplier_worker` | `client_sms_messages` | `UPDATE` | Команда `client-sms:poll-delivery` спрашивает у оператора судьбу сообщений и правит существующую строку журнала (строки листа 5.1–5.2, решение В-204) |
**Зачем вообще.** Отчёт о доставке правит СУЩЕСТВУЮЩУЮ строку, новых не пишет, и это не вопрос
аккуратности: сторож зависших меряет движение рассылки по `MAX(created_at)` журнала сообщений
(`WatchStuckClientSmsCommand`). Новые строки от отчётов делали бы **зависшую рассылку «живой»**
сторож перестал бы её срывать, а деньги клиента остались бы замороженными. Правка строки меняет
`updated_at`, а его сторож не смотрит.
Команда кросс-клиентская (обходит все тенанты), поэтому идёт служебным соединением `pgsql_supplier`.
До Этапа 5 служебная роль журнал сообщений только читала.
🔴 **Без права на бою команда ПАДАЕТ** «нет доступа к таблице» — каждые десять минут. Локально не
видно НИКОГДА: dev и тесты ходят суперпользователем (класс В-126/В-127).
🔴 **Право — одна из ДВУХ опор, и цена отказа у них разная** (В-181): нет права → падение, видно
(сторож расписания запишет отказ); нет разрешающей политики `srv_bypass` из
`db/03_service_bypass_policies.sql` → команда «успешно» правит НОЛЬ строк, тихо. Второе опаснее.
Перезапуск `db/03` при выкате — несущая опора этой команды, а не подстраховка.
**Рабочей роли право не нужно:** она правит журнал под пометкой клиента из кабинета и `UPDATE` у
неё есть с Этапа 3 (миграция `2026_08_01_100400`).
**Сторож.** `MigrationGrantsTest` расширен: у `crm_supplier_worker` на `client_sms_messages` теперь
ожидаются `SELECT, UPDATE`. Проверка спрашивает саму базу (`has_table_privilege`). Проверено
вырезом: гашение `GRANT` в миграции красит сторож.
**Повторный накат.** `GRANT` существующего права — не ошибка. 🔴 Оговорка семейства (В-131): в SQL,
который печатает `migrate --pretend`, защиты от повторного запуска нет.
---
## v9.23 (2026-08-01) — Клиентская СМС, Этап 5: СУДЬБА СООБЩЕНИЯ отдельной графой
**Миграция:** `2026_08_01_101100_add_delivery_to_client_sms_messages.php`. Таблиц не заводит;
у `client_sms_messages` +7 колонок и +1 индекс.
| Колонка | Тип | Зачем |
|---|---|---|
| `delivery_status` | `varchar(16)` NULL | Судьба: `sending` / `delivered` / `not_delivered` / `not_sent` |
| `delivered_at` | `timestamptz` NULL | Когда дошло до человека (`userDeliveryDate` у МТС) |
| `delivery_checked_at` | `timestamptz` NULL | Когда последний раз спрашивали оператора |
| `delivery_raw` | `varchar(32)` NULL | Слово оператора, которое мы не поняли — для разбора, не для логики |
| `provider_cost` | `decimal(14,4)` NULL | Число из ответа МТС **как есть**; единицы неизвестны (В-211), в рубли не переводится |
| `provider_parts` | `smallint` NULL | На сколько частей разбил сообщение сам оператор — сверяем со своим счётом |
| `refunded_at` | `timestamptz` NULL | Когда вернули клиенту деньги за недоставленное (решение владельца В-198) |
**Индекс:** `client_sms_messages_delivery_idx (delivery_status, created_at)` — по нему команда
опроса выбирает, кого спрашивать дальше: незакрытые и свежее трёх суток.
🔴 **Почему отдельная графа, а не переписывание `status`** (решение В-202). По `status` считаются
ДЕНЬГИ и месячный объём клиента: `ClientSmsVolumeCounter` берёт ровно `sent`, сторож зависших
складывает `sent + fake_sent`, журнал авто-СМС считает «ушло» тоже ровно `sent`, и оба словаря
подписей на экране разбирают те же слаги. Заменив `sent` на `delivered`, мы обнулили бы клиенту
накопление за месяц и удешевили бы ему цену задним числом. Значит `status` остаётся фактом
ПЕРЕДАЧИ («мы отдали оператору»), а судьба живёт рядом.
🔴 **Почему `provider_cost` не переводится в рубли.** Единицы поля `cost` в ответе МТС нам
неизвестны — живая проба 30.07 (В-211) записала только сам факт его наличия. Выдумывать ответ
внешней стороны запрещено, поэтому число сохраняется как есть и ни во что не подставляется до
живой сверки с известной ценой.
**Изоляция.** Таблица уже под RLS (`ENABLE` + `FORCE`, политика `tenant_isolation`) с момента
создания — новые колонки этого не меняют. Проверено запросом к базе после наката: RLS
`true / true`, политика на месте.
**GRANT не нужен:** новые колонки наследуют привилегии таблицы (в отличие от новых таблиц).
---
## v9.22 (2026-08-01) — Клиентская СМС, Этап 4: право УДАЛЯТЬ строки журнала сообщений рабочей ролью
**Миграция:** `2026_08_01_101000_grant_app_user_delete_messages.php`. Ни таблиц, ни колонок не заводит — только одно право.
@@ -276,7 +351,7 @@ dev и тесты ходят суперпользователем. Класс В
**Почему этого не было видно.** Локально и в тестах подключение идёт суперпользователем, роли
не применяются. На бою команда падала бы с `permission denied` каждые 15 минут — и камчатская
часть рассылок не дошлалась бы никогда. Тот же класс, что блокер v9.07 (права на счётчики).
часть рассылок не дошла бы никогда. Тот же класс, что блокер v9.07 (права на счётчики).
**Сторож.** `tests/Feature/ClientSms/MigrationGrantsTest.php` расширен: спрашивает саму базу
(`has_table_privilege`) про эти три таблицы для служебной роли. Доказано вырезанием дважды —
@@ -609,7 +684,7 @@ IS NOT NULL`, под запрос джоба «дай созревшие это
— в `client_sms_campaigns` добавлена колонка:
- `with_optout_link` boolean **NOT NULL DEFAULT true** — дописывать ли к тексту
хвост ` Отказ: liderra.ru/s/{token}` (спека §9.2, строки приёмочного листа 1.10–1.13).
хвост `Отказ: liderra.ru/s/{token}` (спека §9.2, строки приёмочного листа 1.10–1.13).
**Почему DEFAULT true.** Рассылка без возможности отказаться — юридический риск;
он выше, чем недовольство ценой. Снять галочку клиент может, но это его явное