36c8ced41eb975ea701083cee6be90b4c1beba16
7 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
36c8ced41e |
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 чисто. |
||
|
|
97a93cb763 |
feat(смс-клиент): кнопка «Продолжить рассылку» — доводим до конца тех, на кого не хватило денег
Строка листа 4.13, решение владельца В-49 «надо обязательно». Рассылка встала из-за денег → клиент пополнил кошелёк → нажал «Продолжить», и она идёт дальше с того места, где остановилась. 🔴 Главная мина (В-151, подтверждена чтением кода): джоб отправки считает обработанным ЛЮБОЙ номер, у которого есть запись в журнале этой рассылки, без разбора статуса. А номер, на котором кончились деньги, записан как «не хватило денег». Значит простой повторный запуск молча пропустил бы его: человек заплатил бы за продолжение, а получатель не получил бы ничего. 🔴 И вторая находка, которой в плане не было (В-184): «оставить строку и просто не считать номер обработанным» НЕВОЗМОЖНО физически — на журнале сообщений уникальный ключ по (клиент, рассылка, номер), вторая запись не вставится. Поэтому продолжение эти строки УДАЛЯЕТ, и по смыслу это верно: «не отправили из-за денег» фиксирует НЕслучившееся и после пополнения перестаёт быть правдой. Заодно замерен масштаб: при остановке пишется РОВНО ОДНА такая строка, у остальных номеров записей нет вовсе. Что сделано: · POST /api/sms/campaigns/{id}/resume — убирает строки «не хватило денег», ставит рассылку в очередь, гасит причину остановки (иначе экран продолжал бы объяснять человеку прошлую остановку у работающей рассылки); · продолжаем ТОЛЬКО остановленную ИЗ-ЗА ДЕНЕГ (В-187). Остановленную человеком продолжать — значит отменить его решение; сорванную сторожем — наступить на ту же поломку. У каждой причины свой человеческий отказ, и текст под тестом; · правило живёт в ОДНОМ месте на сервере (как enableDecision в Task 4): список рассылок отдаёт готовый признак can_resume и число «сколько ещё уйдёт». Счёт остатка — в читателе снимка, где уже живёт «кто ждёт» (одно место, В-96); · кнопка «Продолжить» в таблице рассылок + подтверждение: сколько осталось отправить, что повторно никому не пойдёт, что цена прежняя; · цену НЕ пересчитываем, снимок НЕ пересобираем, заново НЕ замораживаем (В-39, В-188): списание идёт поштучно с проверкой перед каждым номером, в минус рассылка не уйдёт — кончатся деньги, встанет снова с той же причиной. 🔴 Мой же тест поймал расхождение с моим же решением (В-189): проверку денег я впихнул в правило «можно ли продолжить» — и кнопка исчезла бы именно у того, кому она нужна. Разделил: состояние — признак для экрана, деньги — проверка по нажатию с числами («свободно 0.00 ₽, одно сообщение 8.50 ₽»). Право DELETE на журнал сообщений рабочей роли — миграция 2026_08_01_101000, схема v9.22 (В-186, вторая половина мины В-152): без него продолжение на бою падало бы «нет доступа». Сторож MigrationGrantsTest расширен. Проверено: ClientSms 320/320 (13 пачек, все с первой попытки), приём лидов 17/17, фронт 1700 + 3 пропущенных (одна чужая давняя ошибка, В-57), phpstan 2 чужие давние, vue-tsc 5 чужих давних, pint чисто. Шесть вырезов, каждый покраснел ровно там, где вырезан. 🔴 Живой прогон ДЕНЕЖНЫЙ и парный под боевой ролью crm_app_user: рассылка на 5 номеров при деньгах на 2 встала (ушло 2, списано 17.00 ₽, одна строка «не хватило денег»); продолжение без пополнения — отказ с числами; БЕЗ пометки клиента рассылка не видна вовсе (изоляция держит); после пополнения с пометкой — ушло 5 из 5, списано 42.50 ₽, в журнале ровно 5 записей по одной на номер, «не хватило денег» не осталось. В браузере: кнопка видна глазами, после нажатия и прогона очереди рассылка «завершена, 5 из 5», кнопка исчезла, у нетронутой рассылки осталась. Заодно поправлена речь: было «отправим ещё 3 сообщений». Стенд возвращён и сверен; намеренное изменение одно — миграция накатана на локальную dev-базу. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6e29acb7af |
feat(смс-клиент): снимок получателей живёт 90 дней и чистится сам
Строка листа 4.12, решение владельца В-45. Снимок получателей — копия ПЕРСОНАЛЬНЫХ данных: список номеров рассылки с пометками, кому уйдёт и кому нет. Делается один раз при создании рассылки и больше не пересматривается (В-39), то есть после отправки лежит мёртвым грузом. Хранить его вечно нельзя. Что сделано: · команда `client-sms:purge-snapshots` в расписании раз в сутки в 03:30 (проверено schedule:list) уносит строки снимка старше 90 дней. Ходит служебным соединением — она кросс-клиентская и пометку клиента не ставит; · пачками по 5 000: снимок бывает на 20 000 строк, одним DELETE по дате это долгая блокировка. Прибор стоит именно на цикл — 5 001 строка обязана уйти целиком, иначе одна строка ПДн осталась бы жить вечно; · рассылка и её итоги НЕ трогаются: `client_sms_campaigns` и журнал сообщений остаются, как велел владелец. Цена этого названа вслух (В-178): через 90 дней уже нельзя ответить, кто из получателей ждал своего утра; · срок живёт в коде, в админке не правится (В-177): срок зависания рассылки — рабочая настройка, а 90 дней — обязательство про персональные данные, одно для всего портала. При ручном запуске срок передать можно, нулевой отклоняется человеческими словами — он снёс бы снимки живых рассылок; · про снимок НЕзакончившейся рассылки команда говорит вслух и в журнал сервера (В-176): в норме такого не бывает, и молчать об этом нельзя. Право `DELETE` служебной роли — миграция 2026_08_01_100900, схема v9.21 (В-152). Номер и версию взял по каталогу: названные планом были заняты Task 5 — третий раз этот класс (В-175). Сторож `MigrationGrantsTest` расширен и спрашивает саму базу, а не текст миграции. 🔴 Живой прогон под боевой ролью crm_supplier_worker УТОЧНИЛ мину В-152 (В-181). На стенде разрешающих политик srv_bypass нет вовсе (В-179), поэтому бой воспроизведён: политика поставлена тем же текстом, что в db/03_service_bypass_policies.sql, и после прогона убрана. Тройка: (1) политика есть, права нет — команда УПАЛА «нет доступа к таблице», строки целы (план предсказывал «удалит ноль и отрапортует успехом» — в жизни падение); (2) право есть, политики нет — «Удалено строк снимка: 0» с кодом УСПЕХА, вот настоящий тихий ноль; (3) обе опоры — удалено 3 из 4, свежая строка на месте, рассылка и её сообщение на месте, предупреждение о незаконченной рассылке прозвучало. Вывод для выката: без права беда ВИДНА (падение), без политики НЕ видна (успешный ноль). Проверено: ClientSms 315/315 (12 пачек, все с первой попытки), приём лидов 17/17, phpstan 2 чужие давние, pint чисто. Фронт не трогался вовсе — vitest и vue-tsc не гонялись, правок в экранах нет ни одной. Вырезов восемь, каждый покраснел ровно там, где вырезан; девятый — подмена служебного соединения на обычное — НЕ покраснел вообще (6/6 зелёных), и это честный результат: чем ходит команда, ловится только живым прогоном. Стенд возвращён и сверен со снимком ДО; намеренное изменение одно — миграция накатана на dev-базу. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d242819e27 |
fix(смс-клиент): три блокера выката — снимок читался без пометки клиента, правка снимка и базы была без прав
Продолжение приёмки Этапа 3 по указанию владельца: «проверь замечание — так это или нет — и посмотри всё окружение на этот класс ошибок». Замечание оказалось верным, и рядом с ним нашлось ещё два блокера того же семейства. Ни один из трёх не виден ни одному тесту. 🔴 В-125. Джоб отправки читал снимок получателей ВНЕ пометки клиента: строки 115 и 139 стояли голыми в handle(), а обёртка открывалась только внутри цикла. Чтение снимка ленивое — запрос уходит не тогда, когда читателя позвали, а когда забирают очередную пачку, то есть уже за пределами чужой транзакции, а SET LOCAL живёт только до её конца. Замер у самой базы на ОДНОЙ строке снимка: суперпользователь (так идут все наши тесты) — 1 строка, боевая роль crm_app_user без пометки — 0, она же с пометкой — 1. Без ошибки, молча. На бою: рассылка закрывается «готово, отправлено 0»; а если в снимке есть ждущие своего утра — статус «ждёт утра», заморозка НЕ снимается, команда добора будит рассылку каждые 15 минут, та снова читает ноль, и деньги висят замороженными без конца. Починка стоит в самом читателе, а не у зовущего: он оборачивает КАЖДЫЙ свой запрос сам. Полагаться на внимательность каждого, кто его позовёт, оказалось нельзя — ровно на этом дыра и выросла. Зовущим не мешает: под запросом человека пометка уже стоит, вложенная транзакция ставит то же значение. 🔴 В-126 и В-127. Этап 3 научил двух помощников ПРАВИТЬ снимок и клиентскую базу — проставлять найденный у ДаДаты регион, пояс и оператора. А обе таблицы заводились под путь «строки только вставляют и удаляют»: прав на правку им не выдавали. Замер: UPDATE под боевой ролью — «нет доступа к таблице», SELECT той же ролью работает. На бою: номер, у которого пояс не был известен сразу, не получил бы его НИКОГДА, а номер без пояса не отправляется вовсе (решение владельца В-85). Для канала «своя база» пояс не известен ни у одного номера, пока помощник его не проставит, — то есть этот канал не отправил бы ни одного сообщения. Добавочная миграция (прежние прод не перезапускает), гард на существование роли, запись схемы v9.16. Сторож прав расширен: он спрашивал базу только про то, что модуль обещал, и обещание опять оказалось неполным — как в В-122 неделей раньше. ⚠️ В-128, не чиню, называю. В трёх докблоках записано, будто служебная роль обходит изоляцию. ПИЛОТ.md от 07.07: на боевом кластере её не обходит НИ ОДНА роль. Значит служебное соединение живо не обходом, а политиками srv_bypass, и перезапуск db/03_service_bypass_policies.sql при выкате — не подстраховка, а несущая опора. Правка текстов — уровень всего приложения, не этапа. Доказательства. · Тест В-125 проверяет не результат (его подделать нельзя — суперпользователь всё видит), а ПОРЯДОК: каждый запрос к снимку обязан идти внутри той же открытой транзакции, где уже выставлена пометка. Уровень вложенности отличает «пометка здесь и сейчас» от «стояла раньше, в другой, уже закрытой». Красный до починки показал 5 чтений, все без пометки. · Живой прогон НАСТОЯЩЕГО джоба под боевой ролью (SET ROLE crm_app_user), парно: с починкой — «отправлено 2 из 2, статус готово»; без починки — «отправлено 0 из 2, статус готово, джоб не упал». Тот самый молчаливый сбой, вживую. · Права: живой UPDATE под боевой ролью — до миграции «нет доступа» по обеим таблицам, после миграции обе правки проходят. Данные пробы откатаны. · Вырезами трижды: убрал обёртку у чтения снимка — тест краснеет; опечатка в имени роли ВНУТРИ гарда (та самая, что ошибки не даёт и права молча не выдаёт) — сторож краснеет; выдал права только одной таблице из двух — краснеет на второй. Всё возвращено. Обход всего окружения на этот же класс: 35 фоновых помощников и 36 команд классифицированы по тому, ставят ли они пометку клиента и каким соединением ходят. Те, что работают без пометки, трогают только таблицы БЕЗ изоляции (портал продаж, бот, внешние балансы). Отдельно искал именно ловушку «ленивое чтение уезжает из чужой обёртки» — в рекламном модуле и сборщике аудитории всё внутри. Права на автономера у всех новых таблиц выданы обеим ролям, по кошельку перекосов нет. Не проверял вглубь маршруты портала (их закрывает общая прослойка) и модули вне рекламы/СМС. Прогоны: СМС 241/241 (11 пачек, все с первой попытки), приём лидов 17/17, фронт 1676 зелёных + 3 пропущенных (не трогал), phpstan 2 чужие давние, pint чисто. Стенд возвращён: dev-база — те же 12 клиентов и нули по модулю, пробные строки в тестовой базе откатаны. 🔴 При выкате ветки порядок прежний и обязателен: миграции → db/03_service_bypass_policies.sql → контрольный подсчёт политик srv_bypass (должно стать на 8 больше). Права и изоляция — разные механизмы, эта миграция того шага не заменяет. |
||
|
|
6704054a80 |
fix(смс-клиент): приёмка Этапа 3 — служебная роль получила права, экран перестал врать
Этап 3 «Время и цена», Task 8 — приёмка. Строка листа Н.2 (проверка разграничения доступа), итоговая таблица этапа заполнена. 🔴 Блокер выката, найден проверяющим по доступу и подтверждён лично. Команда добора `client-sms:resume-waiting` (появилась в этом этапе, стоит в расписании каждые 15 минут) ходит в базу под служебной ролью `crm_supplier_worker`: она обходит рассылки ВСЕХ клиентов и потому не может работать под клиентской ролью. Прав этой роли на три таблицы, которые она читает и правит, выдано не было — миграции модуля выдавали права рабочей роли, а служебной только на имя отправителя и правило авто-СМС. Локально этого не видно: dev и тесты ходят суперпользователем. На бою команда падала бы с «нет доступа» каждые 15 минут, и камчатская часть рассылок не дошлалась бы НИКОГДА. Тот же класс, что блокер прав на счётчики 27.07. Добавочная миграция (прежние прод не перезапускает), гард на существование роли, запись схемы v9.15. Сторож прав расширен: он спрашивал базу только про то, что модуль обещал, а обещание было неполным — теперь спрашивает и про эти три таблицы. Две неправды на экране, найденные живым прогоном на НЕудобных данных. · Текст в 2325 символов: сервер отказал (потолок 1000), сметы нет, а строчка под текстом пишет «Умещается в 1 СМС за номер». В коде стояло `preview?.segments ?? 1` — на месте «не знаю» подставлялась единица и утверждалась как факт. Теперь без ответа сервера число СМС не называется вовсе: молчание честнее. Дыра не этого этапа (код от 26.07). · Сам отказ был написан по-программистски: «Количество символов в поле body не может превышать 1000». Стало: «Текст длиннее 1000 символов — сократите его.» Вырезами проверено четыре раза: опечатка в имени роли ВНУТРИ гарда (та самая, что ошибки не даёт и права молча не выдаёт) — сторож краснеет; опечатка в цели GRANT — краснеет; убрал человеческий текст отказа — краснеет; вернул подстановку единицы — краснеет. Всё возвращено. Живой прогон строк 3.1–3.15 подряд (29.07, 06:45–07:00 МСК, песочница, локальная база): камчатский номер ушёл сразу, московский стал ждать 10:00, без региона — ждёт уточнения; экран сказал «Отправлено 1 из 3, 1 ждут утра в своих регионах, ещё 1 — уточняем регион»; команда добора до утра дала 0, после наступления срока 1 и номер ушёл, повторный запуск снова 0; ДаДата (ЗАГЛУШКА, боевой ключ не трогали) спрошена ровно один раз — про тот номер, у которого пояса не было; авто-СМС на московский лид отложена ровно на 10:00, при открытом окне три запуска дали одно сообщение; цена прошла 9.00 → 8.50 → 8.00 по накоплению, у отправленной рассылки осталась 9.00; счётчик сложил 500 из рассылки и 500 из авто-СМС; песочные отправки в счётчик не пошли. Стенд возвращён ровно в исходное: окно 10–20, 0 контактов, 0 сообщений, 0 рассылок, 0 снимков, 5 демо-сделок, кошелёк 1000.00 / заморожено 0.00. Прогоны: СМС 240/240 (11 пачек, все с первой попытки), приём лидов 17/17, фронт 1676 зелёных + 3 пропущенных, phpstan 2 чужие давние, vue-tsc 8 чужих давних (проверено git blame), pint чисто. 🪤 Урок приёмки: мой собственный скрипт прогона отрапортовал «красных пачек нет» и НОЛЬ зелёных — разбирал ответ не в том формате. Ноль почти всегда сбой, а не правда о мире. Теперь скрипт считает зелёным только ответ, где прошедших БОЛЬШЕ НУЛЯ. 🔴 При выкате ветки порядок обязателен: миграции → db/03_service_bypass_policies.sql → контрольный подсчёт политик srv_bypass (должно стать на 8 больше). Эта миграция его НЕ заменяет: без srv_bypass команда добора увидит тихий ноль даже с выданными правами. |
||
|
|
51b9c20d0b |
fix(смс-клиент): повторный накат доделывает индекс ключа заказа + сторож прав по трём ролям
Две находки обязательного проверяющего доступа (приёмка Этапа 2, журнал В-80). Обе — про молчаливые поломки: ошибок нет, тесты зелёные, защиты нет. 1. Индекс ключа заказа мог тихо не создаться. Миграция делает два шага — колонку и уникальный индекс, — а защита от повторного запуска стояла общим выходом в начале: «колонка есть, значит всё сделано». На бою SQL подаётся руками; прервалась подача между шагами — колонка легла, индекс нет, повторный накат прошёл мимо. Дальше два одновременных запроса с одним ключом создали бы две рассылки и списали деньги дважды. Проверка стала пошаговой. Доказано вырезанием: до правки новый тест краснеет («защита от двойного заказа потеряна молча»), после — зелёный. Живьём на локальной dev-базе: индекс уронен руками, повторный накат его вернул. 2. Сторож прав спрашивал базу только про рабочую роль. А гардов с именами служебных ролей в миграциях семь, и опечатка внутри такого гарда ошибки НЕ даёт — права просто молча не выдаются. Ровно тот блокер выката, ради которого сторож и заводился (В-36). Теперь спрашиваем и crm_admin_user, и crm_supplier_worker — по матрице, сверенной со всеми GRANT'ами миграций, — и счётчики для всех трёх ролей. Доказано вырезанием: опечатка в имени роли внутри гарда → сторож краснеет с именем роли, таблицы и права. Прогоны: СМС 171/171 (было 169, два новых теста), приём лидов 17/17, pint чисто. Структура таблиц не менялась — запись в журнале схемы v9.11. |
||
|
|
efcb8034a0 |
chore(смс-клиент): убрана врущая графа «кто внёс» + миграции модуля переживают повторный запуск
Хвосты Этапа 1 (журнал В-37). Строк приёмочного листа не закрывают — уборка. 1. Графа «кто внёс» в общем стоп-листе портала снесена. Она была не пустой, а врущей: при открытом в том же браузере обычном кабинете туда записывался id КЛИЕНТСКОГО пользователя — число, неотличимое от id администратора. Проверено пробой: пользователь 1 → в графе 1. Админ-зона закрыта паролем nginx, своего входа Laravel у неё нет, а сессия кабинета видна и там — то же эхо коллизии 24.07. Заполнить правдой нечем: настоящий вход админа ждёт Б-1, соседние экраны пишут id служебной заглушки, то есть одно число во всех строках. Остались номер, причина словами и дата — этого хватает и для разбора жалобы, и для договора с МТС. Возврат — down() миграции. 2. Все 16 миграций модуля начинаются с «уже сделано — выходим», уникальный индекс ключа заказа создаётся с IF NOT EXISTS. Причина не теоретическая: на бою SQL миграций подаётся в базу руками, памяти «этот файл уже применён» там нет. У тарифов и настроек это особенно важно — они засевают строки, и повторный запуск завёл бы ВТОРУЮ строку настроек молча, без ошибки. 3. Восемь GRANT … TO crm_app_user стояли без проверки существования роли — на чистой базе migrate падал бы целиком. Теперь все в гарде, как в v9.00 и v9.07. Своя ловушка гарда: опечатка в имени роли внутри IF EXISTS ошибки НЕ даёт, права просто не выдаются — а это ровно блокер выката В-36. Поэтому заведён сторож: тест спрашивает у самой базы has_table_privilege / has_sequence_privilege по каждой таблице и счётчику модуля. Заодно закрыта дыра — у фикса В-36 теста не было вовсе. Хвост «7 замечаний squawk» проверить его же инструментом нельзя: squawk читает SQL, а миграции у нас PHP. Чужой отчёт не пересказываю — проверка своя и воспроизводимая: тест прогоняет up() каждой миграции второй раз. Тесты: +3 (повторный запуск, права ролей, «чужого следа не остаётся»), один переписан. ClientSms 169/169, приём лидов 17/17, phpstan 0, pint чисто. Три выреза, все покраснели: испорченное имя роли, снятая защита от повторного запуска, возвращённая графа. Живой прогон: вошёл в обычный кабинет (та самая опасная обстановка), внёс номер через админ-раздел — в базе ровно три поля, чужого следа нет; убрал кнопкой. Пять миграций запущены по второму разу прямо на dev-базе — прошли, тарифов 5, строка настроек одна. Контроль: голый CREATE TABLE та же база отвергает. Запись схемы — v9.10. |