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:
@@ -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'],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -2440,3 +2440,7 @@ kanal
|
||||
Директа
|
||||
яндексовы
|
||||
поллинг
|
||||
админской
|
||||
неушедшие
|
||||
тенантного
|
||||
энумератор
|
||||
|
||||
+77
-2
@@ -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.** Рассылка без возможности отказаться — юридический риск;
|
||||
он выше, чем недовольство ценой. Снять галочку клиент может, но это его явное
|
||||
|
||||
Reference in New Issue
Block a user