merge: рабочая ветка сведена в главную — вкладка «Активность» отвечает «жив ли клиент», плюс обзвон, телеграм и запрет переадресации
# Conflicts: # docs/observer/STATUS.md # docs/superpowers/runbooks/2026-08-06-rezultaty-priyomki-telegram.md
@@ -42,6 +42,20 @@ SUPPLIER_PORTAL_URL=https://crm.bp-gr.ru
|
||||
# Supplier alerts (email через Unisender Go relay)
|
||||
SUPPLIER_ALERT_EMAIL=
|
||||
|
||||
# Сторожа машины робота (З-0.10). Машина 51.250.1.97 раз в сутки гоняет своих
|
||||
# сторожей и стучится сюда с итогом. Тревога уходит на MONITORING_ALERT_EMAIL
|
||||
# (он же падает на SUPPLIER_ALERT_EMAIL, тот — на ops@liderra.ru).
|
||||
# 🔴 LENA_STOROZHA_SLOVO — тайное слово подписи тела. Короче 32 знаков или
|
||||
# пусто = дорога закрыта совсем. То же слово кладётся на машину в файл
|
||||
# /home/ubuntu/lena-golos/nastroyki-obhoda (права 600), в хранилище его нет.
|
||||
LENA_STOROZHA_SLOVO=
|
||||
# Кому позволено стучать: адреса/подсети через запятую. Пусто на бою = закрыто.
|
||||
LENA_STOROZHA_ADRESA=
|
||||
# Сколько сторожей ждём в итоге. Прибавили сторожа на машине — прибавьте здесь.
|
||||
LENA_STOROZHA_SKOLKO=3
|
||||
# Сколько часов итог считается свежим (обход ходит раз в сутки).
|
||||
LENA_STOROZHA_SVEZHEST_CHASOV=26
|
||||
|
||||
# SaaS-admin fail-closed гейт (M-1). Логины nginx basic-auth (.htpasswd-admin),
|
||||
# допущенные в /api/admin/*. CSV; дефолт совпадает с прод-.htpasswd.
|
||||
ADMIN_ALLOWED_USERS=admin
|
||||
@@ -150,3 +164,84 @@ TG_ROBOT_CWD=
|
||||
TG_ROBOT_TIMEOUT=300
|
||||
# Канал робота-грузчика креативов в веб-кабинет Яндекса. Пусто → канал закрыт.
|
||||
CREATIVE_ROBOT_TOKEN=
|
||||
|
||||
# ── Поиск клиентов: свой рендер-сервис на виртуалке (решение владельца Р126) ──
|
||||
# Портал просит виртуалку отрисовать страницу 2ГИС/Яндекса и вернуть данные. Тайный ключ
|
||||
# едет заголовком X-Render-Key в КАЖДОМ запросе. Пока адрес или ключ пусты — дверь закрыта:
|
||||
# наружу не ходим, поиск отдаёт пусто и говорит об этом в журнал. Ошибками не сыплет.
|
||||
# 🔴 Р126: только https://. По http:// ключ поехал бы открытым текстом и молча. Адрес без
|
||||
# шифрования портал не принимает. Единственное исключение — разработчик на СВОЁМ компьютере:
|
||||
# при APP_ENV=local (или testing) И адресе на петлю этой же машины (127.0.0.0/8, ::1,
|
||||
# localhost) http:// разрешён — такой запрос компьютер физически не покидает. Тумблера
|
||||
# «можно без шифра» нет намеренно: он и был бы той опечаткой, ради которой запрет заведён.
|
||||
# Боевое значение сегодня: SELF_RENDER_ENDPOINT=https://51-250-1-97.sslip.io/render
|
||||
SELF_RENDER_ENDPOINT=
|
||||
# Тайный ключ портал↔виртуалка (тот же RENDER_KEY, что на самой виртуалке). Только здесь,
|
||||
# в код и в гит НЕ попадает.
|
||||
SELF_RENDER_KEY=
|
||||
# Сколько страниц просим отрисовать ОДНОВРЕМЕННО (браузерная параллельность — внутри виртуалки).
|
||||
SELF_RENDER_CONCURRENCY=8
|
||||
# Ручка проверки живости виртуалки. Пусто → пингуем сам endpoint.
|
||||
SELF_RENDER_HEALTH_URL=
|
||||
# 🔴 Куда порталу ВООБЩЕ разрешено ходить с тайным ключом рендера (хосты через запятую).
|
||||
# Опечатка в SELF_RENDER_ENDPOINT увела бы ключ чужому человеку молча и навсегда. Хост не
|
||||
# из списка = дверь закрыта, запрос не отправлен. Пусто = закрыто всё.
|
||||
# 🔴 Если эту строку не задать, берётся умолчание из config/services.php — тот же список.
|
||||
# Пустым его делать НЕЛЬЗЯ: на боевом строки нет, а поиск работает — пустое умолчание
|
||||
# остановило бы его в момент выката.
|
||||
# 🪤 Разрешённый хост — это ещё не безопасная дорога: http:// к разрешённому хосту всё
|
||||
# равно закрыт. Два запрета независимы, и снимать их надо по отдельности.
|
||||
SELF_RENDER_ALLOWED_HOSTS=51.250.1.97,51-250-1-97.sslip.io,127.0.0.1,localhost
|
||||
|
||||
# ── Прокси автоподбора (Proxy.Market): плитка «Прокси автоподбора» в админке ──
|
||||
# Логин/пароль прокси лежат на ТОЙ ЖЕ виртуалке-рендере. Портал спрашивает её ручку
|
||||
# /proxy-check и предъявляет ТОТ ЖЕ тайный ключ SELF_RENDER_KEY заголовком X-Render-Key.
|
||||
# 🔴 Р126, третья дверь (07.08.2026): адрес проходит ту же проверку, что SELF_RENDER_ENDPOINT —
|
||||
# только https:// и только хост из SELF_RENDER_ALLOWED_HOSTS. Своего списка машин у прокси НЕТ
|
||||
# намеренно: ключ один, значит и список, куда его разрешено везти, обязан быть один.
|
||||
# Дверь закрыта = плитка СЕРАЯ («не проверяем, причина такая-то»), а НЕ красная: красный на
|
||||
# этой плитке значит «прокси кончились» и гонит владельца их продлевать. Врать красным нельзя.
|
||||
# Боевое значение сегодня: PROXY_MARKET_CHECK_URL=https://51-250-1-97.sslip.io/proxy-check
|
||||
PROXY_MARKET_CHECK_URL=
|
||||
# Срок действия купленных прокси — руками при продлении. Пусто/не дата = плитка серая.
|
||||
PROXY_MARKET_EXPIRES_AT=
|
||||
# За сколько дней до конца срока плитка желтеет.
|
||||
PROXY_MARKET_AMBER_DAYS=7
|
||||
# Куда ведёт кнопка «Пополнить» на плитке.
|
||||
PROXY_MARKET_TOPUP_URL=https://proxy.market/
|
||||
|
||||
# ── Обзвон: шов «портал зовёт голосового робота» (З-0.3, решение владельца Р84) ──
|
||||
# Робот живёт на отдельной машине и делит железо с боевым рендером «Поиска клиентов».
|
||||
# Пока адрес или ключ пусты — дверь закрыта: портал наружу не ходит, звонков не будет,
|
||||
# и он говорит об этом вслух. Ошибками не сыплет.
|
||||
# БАЗОВЫЙ адрес приёмника заданий, без хвоста ручки (портал сам добавит /pozvonit и др.).
|
||||
# 🔴 Р123: только https://. Тайный ключ едет заголовком в каждом запросе — по http:// он
|
||||
# поехал бы открытым текстом и молча. Адрес без шифрования портал не принимает: дверь
|
||||
# закрыта, звонков нет. Живой робот 51.250.1.97 слушает 443 уже сегодня.
|
||||
# Единственное исключение — разработчик на СВОЁМ компьютере: при APP_ENV=local (или
|
||||
# testing) И адресе на петлю этой же машины (127.0.0.0/8, ::1, localhost) http:// разрешён,
|
||||
# потому что такой запрос компьютер физически не покидает. Тумблера «можно без шифра»
|
||||
# нет намеренно: он и был бы той опечаткой, ради которой запрет заведён.
|
||||
# Пример для своей машины: OBZVON_ENDPOINT=http://127.0.0.1:8080/lena
|
||||
OBZVON_ENDPOINT=
|
||||
# Тайный ключ портал↔робот. Только здесь, в код и в гит НЕ попадает.
|
||||
OBZVON_KEY=
|
||||
# Сколько обращений к роботу разрешено ОДНОВРЕМЕННО. Предел общий на машину (замки кэша),
|
||||
# а не у каждого работника очереди свой — иначе двое дали бы двойную нагрузку на чужой
|
||||
# боевой рендер. 🪤 Живые разговоры этим НЕ ограничены: место освобождается сразу после
|
||||
# ответа «принял», а разговор идёт минуты.
|
||||
OBZVON_CONCURRENCY=4
|
||||
# Сколько секунд лишнее задание ждёт свободного места. Не дождалось → «робот занят»
|
||||
# (наружу не ходили, звонка точно не было, повторить позже безопасно).
|
||||
OBZVON_WAIT_SECONDS=10
|
||||
# Два предела времени. Первый — дозвониться до машины; второй — весь обмен целиком,
|
||||
# вместе с чтением. Второй нужен против «робот отдаёт ответ по байту бесконечно»:
|
||||
# соединение установилось, а чтение висит и держит работника очереди.
|
||||
OBZVON_CONNECT_TIMEOUT=5
|
||||
OBZVON_TIMEOUT=15
|
||||
# 🔴 Куда порталу ВООБЩЕ разрешено ходить с тайным ключом (хосты через запятую).
|
||||
# Опечатка в OBZVON_ENDPOINT увела бы ключ чужому человеку молча. Хост не из списка =
|
||||
# дверь закрыта. Пусто = закрыто всё. Переезд робота — осознанная правка в двух местах.
|
||||
# 🪤 Разрешённый хост — это ещё не безопасная дорога: http:// к разрешённому хосту всё
|
||||
# равно закрыт (Р123). Два запрета независимы, и снимать их надо по отдельности.
|
||||
OBZVON_ALLOWED_HOSTS=51.250.1.97,51-250-1-97.sslip.io,127.0.0.1,localhost
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\LenaStorozhaTrevogaMail;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Ежедневная проверка суточного обхода сторожей на машине робота (З-0.10).
|
||||
*
|
||||
* Что проверяем — ровно две вещи, и вторая важнее первой:
|
||||
* 1. ИТОГ КРАСНЫЙ — какой-то сторож на машине покраснел.
|
||||
* 2. ИТОГ ПРОТУХ — обход не приходил дольше положенного.
|
||||
*
|
||||
* 🔴 Пункт 2 — сердце всей затеи. Зелёный итог недельной давности ХУЖЕ
|
||||
* красного сегодняшнего: он усыпляет. Умри обход на машине тихо — и без
|
||||
* проверки возраста портал вечно показывал бы последний зелёный итог, а
|
||||
* записи чужих голосов копились бы месяцами. Проверка возраста работает
|
||||
* ВСЕГДА, даже когда внутри итога всё зелено.
|
||||
*
|
||||
* Письмо уходит на адрес ИЗ НАСТРОЙКИ (решение владельца Р117):
|
||||
* MONITORING_ALERT_EMAIL → SUPPLIER_ALERT_EMAIL → ops@liderra.ru.
|
||||
* 🔴 Не повторяем здешнюю беду: соседний сторож пульса (SchedulerCheckHeartbeats)
|
||||
* шлёт на личную почту, ЖЁСТКО ВПИСАННУЮ в код. Тот код чужой и боевой, мы его
|
||||
* не трогаем, но и не копируем.
|
||||
*
|
||||
* При здоровье письма НЕТ. Ежедневное «всё хорошо» перестают читать через
|
||||
* неделю, и тогда пропадает и настоящая тревога.
|
||||
*
|
||||
* Кто зовёт: routes/console.php, ежедневно. Сама эта команда записана в
|
||||
* SchedulerHeartbeatTracker::EXPECTED_INTERVALS — то есть за ней самой следит
|
||||
* штатный часовой сторож пульса. Иначе мы завели бы молчуна вторым слоем:
|
||||
* портал ждёт стука, а что проверка стука жива — не проверяет никто.
|
||||
*/
|
||||
final class LenaProverkaObhodaStorozhey extends Command
|
||||
{
|
||||
protected $signature = 'lena:proverka-obhoda {--suho : только сказать, письма не слать}';
|
||||
|
||||
protected $description = 'Проверяет суточный обход сторожей на машине робота: свежесть итога и красноту сторожей';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$imya = (string) config('storozha_mashiny.imya_otmetki');
|
||||
$chasov = (int) config('storozha_mashiny.svezhest_chasov');
|
||||
|
||||
$stroka = DB::connection('pgsql_supplier')
|
||||
->table('scheduler_heartbeats')
|
||||
->where('command_name', $imya)
|
||||
->first();
|
||||
|
||||
$prichina = null;
|
||||
$podrobno = null;
|
||||
$sobranoV = null;
|
||||
$vozrastChasov = null;
|
||||
|
||||
if ($stroka === null || $stroka->last_run_at === null) {
|
||||
$prichina = 'итога обхода сторожей НЕ БЫЛО НИ РАЗУ';
|
||||
$podrobno = 'Машина 51.250.1.97 ни разу не постучалась в портал. '
|
||||
.'Либо расписание /etc/cron.d/lena-sutochnyy-obhod не заведено, '
|
||||
.'либо отправка не настроена, либо стук не доходит.';
|
||||
} else {
|
||||
$sobranoV = Carbon::parse($stroka->last_run_at);
|
||||
$vozrastChasov = (int) $sobranoV->diffInHours(now());
|
||||
|
||||
if ($vozrastChasov > $chasov) {
|
||||
// 🔴 Сначала возраст, и только потом содержание. Протухший
|
||||
// ЗЕЛЁНЫЙ итог обязан поднимать тревогу так же, как красный.
|
||||
$prichina = "итог обхода ПРОТУХ: собран {$vozrastChasov} ч назад, а ждём не старше {$chasov} ч";
|
||||
$podrobno = "Дата сборки итога (не получения!): {$sobranoV->toIso8601String()}. "
|
||||
.($stroka->last_error === null
|
||||
? 'Внутри последнего итога всё было зелено — тем хуже: зелёный старый итог усыпляет.'
|
||||
: 'Внутри последнего итога было: '.$stroka->last_error);
|
||||
} elseif ($stroka->last_error !== null) {
|
||||
$prichina = 'сторож на машине робота ПОКРАСНЕЛ';
|
||||
$podrobno = $stroka->last_error;
|
||||
}
|
||||
}
|
||||
|
||||
if ($prichina === null) {
|
||||
$this->info('Обход сторожей свеж и зелен. Письма нет — и не должно быть.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->warn('Тревога: '.$prichina);
|
||||
$this->line((string) $podrobno);
|
||||
|
||||
if ($this->option('suho')) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$komu = (string) config('services.monitoring.alert_email', 'ops@liderra.ru');
|
||||
Mail::to($komu)->send(new LenaStorozhaTrevogaMail(
|
||||
prichina: $prichina,
|
||||
podrobno: (string) $podrobno,
|
||||
sobranoV: $sobranoV?->toIso8601String(),
|
||||
vozrastChasov: $vozrastChasov,
|
||||
skolkoNeudach: $stroka !== null ? (int) $stroka->consecutive_failures : 0,
|
||||
));
|
||||
|
||||
$this->info('Письмо отправлено на '.$komu);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,11 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\SaasAdminAuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Admin\TenantActivityFeedService;
|
||||
use App\Services\Admin\TenantAlivenessService;
|
||||
use App\Support\MskBoundary;
|
||||
use Carbon\CarbonImmutable;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -30,6 +34,9 @@ class AdminTenantsController extends Controller
|
||||
{
|
||||
use ResolvesAdminUserId;
|
||||
|
||||
/** Сколько дел отдаём одной страницей ленты. */
|
||||
private const ACTIVITY_PAGE = 50;
|
||||
|
||||
/** GET /api/admin/tenants?status=&statuses=&tariffs=&search=&limit=&offset= */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
@@ -42,6 +49,15 @@ class AdminTenantsController extends Controller
|
||||
$limit = max(1, min(500, (int) $request->query('limit', '100')));
|
||||
$offset = max(0, (int) $request->query('offset', '0'));
|
||||
|
||||
// Последний вход — из журнала входов. Колонка tenants.last_activity_at
|
||||
// мертва: её никто не пишет (замерено 07.08.2026 — ноль из одиннадцати),
|
||||
// и сортировка по ней работала вслепую.
|
||||
$stripSince = MskBoundary::instant(
|
||||
CarbonImmutable::now('Europe/Moscow')
|
||||
->subDays(TenantAlivenessService::STRIP_DAYS - 1)
|
||||
->startOfDay()
|
||||
);
|
||||
|
||||
$query = Tenant::query()
|
||||
->leftJoin('tariff_plans', 'tariff_plans.id', '=', 'tenants.current_tariff_id')
|
||||
->select([
|
||||
@@ -53,7 +69,10 @@ class AdminTenantsController extends Controller
|
||||
'tenants.balance_rub',
|
||||
'tenants.balance_leads',
|
||||
'tenants.is_trial',
|
||||
'tenants.last_activity_at',
|
||||
DB::raw('(SELECT max(al.created_at) FROM auth_log al
|
||||
WHERE al.tenant_id = tenants.id
|
||||
AND al.event IN (\'login_success\',\'register_success\')
|
||||
AND al.created_at >= ?) AS last_activity_at'),
|
||||
'tenants.current_tariff_id',
|
||||
'tenants.desired_daily_numbers',
|
||||
'tenants.chargeback_unrecovered_rub',
|
||||
@@ -61,6 +80,7 @@ class AdminTenantsController extends Controller
|
||||
'tariff_plans.name as tariff_name',
|
||||
'tariff_plans.price_monthly as tariff_price_monthly',
|
||||
])
|
||||
->addBinding($stripSince, 'select')
|
||||
->whereNull('tenants.deleted_at');
|
||||
|
||||
// Производный статус — зеркалит adminTenantsMapper.deriveStatus (фронт):
|
||||
@@ -92,7 +112,7 @@ class AdminTenantsController extends Controller
|
||||
$total = (clone $query)->count('tenants.id');
|
||||
|
||||
$rows = $query
|
||||
->orderByDesc('tenants.last_activity_at')
|
||||
->orderByRaw('last_activity_at DESC NULLS LAST')
|
||||
->orderBy('tenants.id')
|
||||
->limit($limit)
|
||||
->offset($offset)
|
||||
@@ -111,6 +131,7 @@ class AdminTenantsController extends Controller
|
||||
'last_activity_at' => $t->last_activity_at !== null
|
||||
? CarbonImmutable::parse($t->last_activity_at)->toIso8601String()
|
||||
: null,
|
||||
'aliveness_state' => $this->listState($t->last_activity_at),
|
||||
'tariff_id' => $t->current_tariff_id !== null ? (int) $t->current_tariff_id : null,
|
||||
'tariff_name' => $t->tariff_name,
|
||||
// mrr_rub = price_monthly если активный тариф + не-trial; иначе null.
|
||||
@@ -200,7 +221,8 @@ class AdminTenantsController extends Controller
|
||||
'users' => $this->fetchUsers($tenantId),
|
||||
'projects' => $this->fetchProjects($tenantId),
|
||||
'balance_history' => $this->fetchBalanceHistory($tenantId),
|
||||
'activity' => $this->fetchActivity($tenantId),
|
||||
'aliveness' => app(TenantAlivenessService::class)->forTenant($tenantId),
|
||||
'activity' => $this->activityPage($tenantId, self::ACTIVITY_PAGE, 0),
|
||||
'metrics' => $this->computeMetrics($tenantId, $tenant),
|
||||
]);
|
||||
}
|
||||
@@ -380,33 +402,68 @@ class AdminTenantsController extends Controller
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
private function fetchActivity(int $tenantId): array
|
||||
/**
|
||||
* GET /api/admin/tenants/{subdomain}/activity — подгрузка следующей страницы
|
||||
* ленты дел («показать ещё»). Отдельная ручка, чтобы не пересобирать всю
|
||||
* карточку ради пятидесяти строк.
|
||||
*/
|
||||
public function activity(Request $request, string $subdomain): JsonResponse
|
||||
{
|
||||
return DB::table('activity_log')
|
||||
->leftJoin('users', 'users.id', '=', 'activity_log.user_id')
|
||||
->where('activity_log.tenant_id', $tenantId)
|
||||
->orderByDesc('activity_log.created_at')
|
||||
->orderByDesc('activity_log.id')
|
||||
->limit(20)
|
||||
->select([
|
||||
'activity_log.id',
|
||||
'activity_log.event',
|
||||
'activity_log.deal_id',
|
||||
'activity_log.context',
|
||||
'activity_log.created_at',
|
||||
'users.email as actor_email',
|
||||
])
|
||||
->get()
|
||||
->map(fn ($ev) => [
|
||||
'id' => (int) $ev->id,
|
||||
'event' => $ev->event,
|
||||
'deal_id' => (int) $ev->deal_id,
|
||||
'actor_email' => $ev->actor_email,
|
||||
'context' => $ev->context !== null ? json_decode($ev->context, true) : null,
|
||||
'created_at' => CarbonImmutable::parse($ev->created_at)->toIso8601String(),
|
||||
])
|
||||
->all();
|
||||
$tenant = DB::table('tenants')
|
||||
->where('subdomain', $subdomain)
|
||||
->whereNull('deleted_at')
|
||||
->first(['id']);
|
||||
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
$limit = max(1, min(self::ACTIVITY_PAGE, (int) $request->query('limit', (string) self::ACTIVITY_PAGE)));
|
||||
$offset = max(0, (int) $request->query('offset', '0'));
|
||||
|
||||
return response()->json($this->activityPage((int) $tenant->id, $limit, $offset));
|
||||
}
|
||||
|
||||
/** @return array{items: array<int, array<string, mixed>>, offset: int, limit: int, has_more: bool} */
|
||||
private function activityPage(int $tenantId, int $limit, int $offset): array
|
||||
{
|
||||
$feed = app(TenantActivityFeedService::class);
|
||||
|
||||
return [
|
||||
'items' => $feed->forTenant($tenantId, $limit, $offset),
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'has_more' => $feed->hasMore($tenantId, $limit, $offset),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Состояние клиента для списка. Пустое значение означает «за девяносто дней
|
||||
* входов не было» — для списка это то же самое, что «пропал»; тонкое различие
|
||||
* «зарегистрировался и ни разу не вошёл» живёт в карточке, где для него есть
|
||||
* место под подпись.
|
||||
*
|
||||
* Тип входа нарочно широкий: Eloquent кастит псевдоним last_activity_at
|
||||
* в Carbon по правилам модели Tenant, а из сырого запроса пришла бы строка.
|
||||
*/
|
||||
private function listState(DateTimeInterface|string|null $lastLoginAt): string
|
||||
{
|
||||
if ($lastLoginAt === null) {
|
||||
return 'lost';
|
||||
}
|
||||
|
||||
$days = (int) CarbonImmutable::parse($lastLoginAt)
|
||||
->setTimezone('Europe/Moscow')->startOfDay()
|
||||
->diffInDays(CarbonImmutable::now('Europe/Moscow')->startOfDay());
|
||||
|
||||
if ($days >= TenantAlivenessService::LOST_AFTER_DAYS) {
|
||||
return 'lost';
|
||||
}
|
||||
if ($days >= TenantAlivenessService::COOLING_AFTER_DAYS) {
|
||||
return 'cooling';
|
||||
}
|
||||
|
||||
return 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\IpUtils;
|
||||
|
||||
/**
|
||||
* Приём суточного итога сторожей с машины робота (З-0.10).
|
||||
*
|
||||
* URL: POST /api/webhook/lena-storozha
|
||||
*
|
||||
* Кто стучится: /home/ubuntu/lena-golos/sutochnyy-obhod.sh на машине
|
||||
* 51.250.1.97, раз в сутки в 02:20 UTC (05:20 МСК), по расписанию
|
||||
* /etc/cron.d/lena-sutochnyy-obhod. Эталоны обоих файлов — bots/lena-golos/.
|
||||
*
|
||||
* 🔴 ПОЧЕМУ СТУЧИТСЯ МАШИНА, А НЕ ПОРТАЛ ХОДИТ (решение владельца Р116).
|
||||
* «Портал сам ходит на машину» пришлось бы оплатить ключом от машины, лежащим
|
||||
* на боевом портале, — а на той машине голоса живых людей. «Машина шлёт письмо
|
||||
* сама» отпало иначе: почтовых программ на ней нет, и молчание такой машины не
|
||||
* заметил бы НИКТО.
|
||||
*
|
||||
* Защита — как у вебхука поставщика, но с одной поправкой в лучшую сторону:
|
||||
* 1. Подпись тела (X-Storozh-Podpis: sha256=…, HMAC-SHA256 на тайном слове).
|
||||
* Слово в АДРЕС не кладём: адреса целиком текут в журналы nginx, тело —
|
||||
* нет. У поставщика слово в адресе осталось от старого уговора, повторять
|
||||
* это здесь незачем.
|
||||
* 2. Список разрешённых адресов. На бою пустой список = закрыто.
|
||||
* Не сошлось — 404, чтобы не подтверждать существование двери.
|
||||
*
|
||||
* 🔴 ЧТО ЗДЕСЬ САМОЕ ВАЖНОЕ И ЛЕГКО ПОТЕРЯТЬ ПРИ ПРАВКЕ.
|
||||
* В отметку кладётся дата ИЗ САМОГО ИТОГА (sobrano_v), а НЕ время получения.
|
||||
* Иначе вся затея разваливается: кто-то отправит вчерашний файл сегодня —
|
||||
* и портал спокойно запишет «свежий», хотя сторожа не запускались вовсе.
|
||||
* Возраст обязан считаться по дате сборки итога.
|
||||
*
|
||||
* Итог складывается в scheduler_heartbeats — таблицу отметок пульса, которая
|
||||
* у портала уже есть. Своей таблицы не заводим: итог обхода — ровно такая же
|
||||
* отметка «эта работа была сделана тогда-то и вот с каким исходом».
|
||||
*/
|
||||
class LenaStorozhaWebhookController extends Controller
|
||||
{
|
||||
/** Насколько дата итога может опережать наши часы, прежде чем мы ей не поверим. */
|
||||
private const ZAPAS_VPERYOD_MINUT = 10;
|
||||
|
||||
/**
|
||||
* Поля итога, которые дверь знает ПОИМЁННО.
|
||||
*
|
||||
* 🔴 Список нужен, чтобы считать проверенное ВЫЧИТАНИЕМ, а не перечислением.
|
||||
* Перечисление («проверяем вот эти поля») оставляет всё остальное мимо
|
||||
* защиты: добавят на машине новое поле, забудут добавить сюда — и оно
|
||||
* молча проедет. Вычитание («всё, чего нет в этом списке, — новость»)
|
||||
* ставит забытое поле ПОД защиту: дверь заругается, пока человек не
|
||||
* посмотрит на него и не впишет сюда осознанно.
|
||||
*
|
||||
* Цена решения названа честно: безобидная прибавка поля на машине красит
|
||||
* итог до правки этого списка. Так же устроено и число ожидаемых сторожей
|
||||
* в настройке — прибавление подтверждает человек, а не догадка портала.
|
||||
*/
|
||||
private const ZNAKOMYE_POLYA_ITOGA = [
|
||||
'shema', // разновидность итога — проверена отдельно
|
||||
'sobrano_v', // дата сборки — разбирается в receive()
|
||||
'mashina', // кто прислал — знаем, нарочно не проверяем
|
||||
'dlilos_ms', // сколько длился обход — ложится в отметку
|
||||
'storozhey_vsego', // объявленное число сторожей
|
||||
'storozhey_krasnyh', // объявленное число красных
|
||||
'slepok_etalonov', // слепок папки — сверить не с чем, лежит для истории
|
||||
'storozha', // сами строки сторожей
|
||||
];
|
||||
|
||||
/** Поля строки сторожа, которые дверь знает поимённо. Остальное — новость. */
|
||||
private const ZNAKOMYE_POLYA_STOROZHA = ['imya', 'kod', 'proverok', 'provalov'];
|
||||
|
||||
public function receive(Request $request): JsonResponse
|
||||
{
|
||||
if (! $this->podpisVerna($request)) {
|
||||
$this->skazatVZhurnal('otkaz_podpis', $request->ip(), null);
|
||||
|
||||
return response()->json(['message' => 'Not found.'], 404);
|
||||
}
|
||||
|
||||
if (! $this->adresRazreshyon($request->ip())) {
|
||||
$this->skazatVZhurnal('otkaz_adres', $request->ip(), null);
|
||||
|
||||
return response()->json(['message' => 'Not found.'], 404);
|
||||
}
|
||||
|
||||
$itog = $request->json()->all();
|
||||
$bedy = $this->chtoNeTak($itog);
|
||||
|
||||
// Дата сборки итога. Не разобрали — не выдумываем «сейчас»: строка без
|
||||
// собственной даты бесполезна как датчик свежести, поэтому кладём такую
|
||||
// дату, при которой итог заведомо протух, и говорим об этом в ошибке.
|
||||
$sobranoV = $this->razobratDatu($itog['sobrano_v'] ?? null);
|
||||
if ($sobranoV === null) {
|
||||
$bedy[] = 'дата сборки итога непонятна или её нет вовсе';
|
||||
$sobranoV = Carbon::createFromTimestampUTC(0);
|
||||
} elseif ($sobranoV->greaterThan(now()->addMinutes(self::ZAPAS_VPERYOD_MINUT))) {
|
||||
$bedy[] = 'дата сборки итога в будущем: '.$sobranoV->toIso8601String();
|
||||
}
|
||||
|
||||
$this->polozhitOtmetku($itog, $sobranoV, $bedy);
|
||||
$this->skazatVZhurnal($bedy === [] ? 'prinyat' : 'prinyat_krasnym', $request->ip(), $bedy);
|
||||
|
||||
// 202 и при красном итоге: стук ПРИНЯТ, а краснота — это его содержание,
|
||||
// а не ошибка отправителя. Ответь мы 4xx — машина решила бы, что стук не
|
||||
// дошёл, и красный итог остался бы неучтённым.
|
||||
return response()->json([
|
||||
'status' => $bedy === [] ? 'принят' : 'принят, но красный',
|
||||
'bedy' => $bedy,
|
||||
], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* Что не так с итогом. Пустой список = всё зелено.
|
||||
*
|
||||
* 🔴 ЧТО ЗДЕСЬ ЛЕГКО ПОТЕРЯТЬ ПРИ ПРАВКЕ — СВЯЗНОСТЬ ТОГО, ЧТО ПРИНЯЛИ.
|
||||
* Первый заход этой двери стоял на допущении «список storozha ПОЛОН,
|
||||
* поэтому красноту можно пересчитать по нему, а собственное число красных
|
||||
* из итога читать незачем». Допущение оказалось ложным дважды:
|
||||
*
|
||||
* 1. Длина списка ни с чем не сверялась. Тело «сторожей 3», а в списке
|
||||
* одна зелёная строка — двое сторожей исчезали, и портал записывал
|
||||
* «всё хорошо».
|
||||
* 2. Поле storozhey_krasnyh не читалось вовсе. Машина присылала своё
|
||||
* показание, а дверь его выбрасывала: тело «красных 3» при трёх
|
||||
* зелёных строках проезжало насквозь.
|
||||
*
|
||||
* Поэтому здесь сверяются ТРИ независимых показания об одном и том же:
|
||||
* сколько сторожей ждёт настройка, сколько объявил итог и сколько строк
|
||||
* в списке на самом деле. Расхождение любых двух — само по себе беда,
|
||||
* даже когда каждая строка по отдельности зелёная. Это не дублирование:
|
||||
* согласие трёх показаний и есть единственное доказательство, что список
|
||||
* не обрезан и не подменён.
|
||||
*
|
||||
* @param array<string, mixed> $itog
|
||||
* @return list<string>
|
||||
*/
|
||||
private function chtoNeTak(array $itog): array
|
||||
{
|
||||
$bedy = [];
|
||||
|
||||
if ((int) ($itog['shema'] ?? 0) !== 1) {
|
||||
$bedy[] = 'непонятная разновидность итога: '.var_export($itog['shema'] ?? null, true);
|
||||
|
||||
return $bedy; // Дальше разбирать нечего — устройство файла нам неизвестно.
|
||||
}
|
||||
|
||||
// Вычитанием, а не перечислением: всё, чего дверь не знает поимённо, —
|
||||
// новость, о которой надо сказать вслух. См. ZNAKOMYE_POLYA_ITOGA.
|
||||
$novyePolya = array_values(array_diff(array_keys($itog), self::ZNAKOMYE_POLYA_ITOGA));
|
||||
if ($novyePolya !== []) {
|
||||
$bedy[] = 'в итоге поля, которых дверь не знает: '.implode(', ', $novyePolya);
|
||||
}
|
||||
|
||||
$zhdyom = (int) config('storozha_mashiny.ozhidaem_storozhey');
|
||||
$vsego = (int) ($itog['storozhey_vsego'] ?? -1);
|
||||
|
||||
// 🔴 Тот самый случай мимо допущения «сторожей три»: сторожей стало
|
||||
// четыре, а стучит по-прежнему три (или наоборот). Молчать нельзя.
|
||||
if ($vsego !== $zhdyom) {
|
||||
$bedy[] = "сторожей в обходе {$vsego}, а ждём {$zhdyom}";
|
||||
}
|
||||
|
||||
$spisok = $itog['storozha'] ?? null;
|
||||
if (! is_array($spisok) || $spisok === []) {
|
||||
$bedy[] = 'в итоге нет ни одного сторожа';
|
||||
|
||||
return $bedy;
|
||||
}
|
||||
|
||||
// Второе показание против первого: список обязан быть той длины,
|
||||
// какую итог сам же и объявил. Короткий список = сторожа исчезли.
|
||||
if (count($spisok) !== $vsego) {
|
||||
$bedy[] = 'строк в списке сторожей '.count($spisok).", а объявлено {$vsego}";
|
||||
}
|
||||
|
||||
$krasnyhPoSpisku = 0;
|
||||
$imena = [];
|
||||
$novyePolyaStrok = [];
|
||||
|
||||
foreach ($spisok as $s) {
|
||||
if (! is_array($s)) {
|
||||
$bedy[] = 'строка сторожа не разобрана';
|
||||
// Не разобрали — считаем красной. Звать её зелёной не за что,
|
||||
// и наш счёт красных обязан остаться сравнимым с машинным.
|
||||
$krasnyhPoSpisku++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$novyePolyaStrok = array_merge(
|
||||
$novyePolyaStrok,
|
||||
array_diff(array_keys($s), self::ZNAKOMYE_POLYA_STOROZHA),
|
||||
);
|
||||
|
||||
$imya = (string) ($s['imya'] ?? '(без имени)');
|
||||
$imena[] = $imya;
|
||||
$kod = (int) ($s['kod'] ?? -1);
|
||||
$proverok = (int) ($s['proverok'] ?? -1);
|
||||
$provalov = (int) ($s['provalov'] ?? -1);
|
||||
|
||||
if ($kod !== 0 || $provalov !== 0) {
|
||||
// 🔴 Красным считаем РОВНО ПО ТОМУ ЖЕ правилу, по какому считает
|
||||
// обход на машине (код не ноль ИЛИ провалов не ноль). Иначе
|
||||
// сверка двух чисел ниже ругалась бы на ровном месте.
|
||||
$krasnyhPoSpisku++;
|
||||
$bedy[] = "{$imya}: код {$kod}, проверок {$proverok}, красных {$provalov}";
|
||||
} elseif ($proverok <= 0) {
|
||||
// Зелёный сторож, не сделавший ни одной проверки, — не сторож.
|
||||
// 🔴 В счёт красных НЕ идёт: машина такого красным не считает,
|
||||
// а мы сверяемся именно с её счётом.
|
||||
$bedy[] = "{$imya}: код 0, но проверок {$proverok} — сторож ничего не проверил";
|
||||
}
|
||||
}
|
||||
|
||||
if ($novyePolyaStrok !== []) {
|
||||
$bedy[] = 'в строках сторожей поля, которых дверь не знает: '
|
||||
.implode(', ', array_values(array_unique($novyePolyaStrok)));
|
||||
}
|
||||
|
||||
// Длину списка можно добрать повторами, и тогда все числа сойдутся,
|
||||
// а гоняли одного сторожа трижды. Имена сторожей — имена файлов на
|
||||
// машине, повторяться они не могут.
|
||||
$povtory = array_keys(array_filter(array_count_values($imena), static fn (int $skolko): bool => $skolko > 1));
|
||||
if ($povtory !== []) {
|
||||
$bedy[] = 'в списке сторожей повторяются имена: '.implode(', ', $povtory);
|
||||
}
|
||||
|
||||
// Третье показание: машина сама сказала, скольких считает красными.
|
||||
// Не сошлось с нашим счётом — беда, даже если каждая строка зелена.
|
||||
if (! array_key_exists('storozhey_krasnyh', $itog)) {
|
||||
$bedy[] = 'в итоге нет числа красных — наш счёт сверить не с чем';
|
||||
} elseif ((int) $itog['storozhey_krasnyh'] !== $krasnyhPoSpisku) {
|
||||
$objavleno = (int) $itog['storozhey_krasnyh'];
|
||||
$bedy[] = "красных по списку {$krasnyhPoSpisku}, а объявлено {$objavleno}";
|
||||
}
|
||||
|
||||
return $bedy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Кладёт итог в scheduler_heartbeats.
|
||||
*
|
||||
* @param array<string, mixed> $itog
|
||||
* @param list<string> $bedy
|
||||
*/
|
||||
private function polozhitOtmetku(array $itog, Carbon $sobranoV, array $bedy): void
|
||||
{
|
||||
$imya = (string) config('storozha_mashiny.imya_otmetki');
|
||||
$now = now();
|
||||
$db = DB::connection('pgsql_supplier');
|
||||
$dlilos = (int) ($itog['dlilos_ms'] ?? 0);
|
||||
|
||||
if ($bedy === []) {
|
||||
$db->statement(
|
||||
<<<'SQL'
|
||||
INSERT INTO scheduler_heartbeats
|
||||
(command_name, last_run_at, last_success_at, last_error, runtime_ms, consecutive_failures, created_at, updated_at)
|
||||
VALUES (?, ?, ?, NULL, ?, 0, ?, ?)
|
||||
ON CONFLICT (command_name) DO UPDATE SET
|
||||
last_run_at = EXCLUDED.last_run_at,
|
||||
last_success_at = EXCLUDED.last_success_at,
|
||||
last_error = NULL,
|
||||
runtime_ms = EXCLUDED.runtime_ms,
|
||||
consecutive_failures = 0,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
SQL,
|
||||
[$imya, $sobranoV, $sobranoV, $dlilos, $now, $now],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tekst = substr(implode('; ', $bedy), 0, 2000);
|
||||
|
||||
$db->statement(
|
||||
<<<'SQL'
|
||||
INSERT INTO scheduler_heartbeats
|
||||
(command_name, last_run_at, last_success_at, last_error, runtime_ms, consecutive_failures, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, ?, ?, 1, ?, ?)
|
||||
ON CONFLICT (command_name) DO UPDATE SET
|
||||
last_run_at = EXCLUDED.last_run_at,
|
||||
last_error = EXCLUDED.last_error,
|
||||
runtime_ms = EXCLUDED.runtime_ms,
|
||||
consecutive_failures = scheduler_heartbeats.consecutive_failures + 1,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
SQL,
|
||||
[$imya, $sobranoV, $tekst, $dlilos, $now, $now],
|
||||
);
|
||||
}
|
||||
|
||||
private function podpisVerna(Request $request): bool
|
||||
{
|
||||
$slovo = (string) config('storozha_mashiny.tainoe_slovo');
|
||||
if (strlen($slovo) < 32) {
|
||||
// Дорога не открывается сама собой: слова нет — двери нет.
|
||||
return false;
|
||||
}
|
||||
|
||||
$podpis = (string) $request->header('X-Storozh-Podpis', '');
|
||||
$podpis = str_starts_with($podpis, 'sha256=') ? substr($podpis, 7) : $podpis;
|
||||
if ($podpis === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals(hash_hmac('sha256', $request->getContent(), $slovo), $podpis);
|
||||
}
|
||||
|
||||
private function adresRazreshyon(?string $adres): bool
|
||||
{
|
||||
if ($adres === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$spisok = array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) config('storozha_mashiny.adresa'))
|
||||
)));
|
||||
|
||||
if ($spisok === []) {
|
||||
// На бою пустой список = закрыто (как у вебхука поставщика: пустой
|
||||
// список на проде значит «забыли заполнить», а не «пускать всех»).
|
||||
return ! app()->environment('production');
|
||||
}
|
||||
|
||||
return IpUtils::checkIp($adres, $spisok);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string>|null $bedy
|
||||
*/
|
||||
private function skazatVZhurnal(string $chto, ?string $adres, ?array $bedy): void
|
||||
{
|
||||
// 🔴 Уровень warning нарочно, а не info: на боевом LOG_LEVEL=warning,
|
||||
// и всё ниже предупреждения не доезжает до журнала НИКОГДА.
|
||||
Log::warning('lena_storozha.'.$chto, [
|
||||
'ip' => $adres,
|
||||
'bedy' => $bedy,
|
||||
]);
|
||||
}
|
||||
|
||||
private function razobratDatu(mixed $syryo): ?Carbon
|
||||
{
|
||||
if (! is_string($syryo) || $syryo === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($syryo);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Тревога по суточному обходу сторожей на машине робота (З-0.10).
|
||||
*
|
||||
* Шлётся только когда плохо: итог протух ИЛИ сторож покраснел. При здоровье
|
||||
* письма нет — ежедневное «всё хорошо» перестают читать, и вместе с ним
|
||||
* перестают замечать настоящую беду.
|
||||
*
|
||||
* Адрес получателя берётся из настройки (решение владельца Р117), не отсюда.
|
||||
*/
|
||||
final class LenaStorozhaTrevogaMail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $prichina,
|
||||
public readonly string $podrobno,
|
||||
public readonly ?string $sobranoV,
|
||||
public readonly ?int $vozrastChasov,
|
||||
public readonly int $skolkoNeudach,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: '[Лидерра HIGH] Сторожа машины робота: '.$this->prichina,
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
text: 'emails.lena_storozha_trevoga',
|
||||
with: [
|
||||
'prichina' => $this->prichina,
|
||||
'podrobno' => $this->podrobno,
|
||||
'sobranoV' => $this->sobranoV,
|
||||
'vozrastChasov' => $this->vozrastChasov,
|
||||
'skolkoNeudach' => $this->skolkoNeudach,
|
||||
'seychas' => now()->timezone('Europe/Moscow')->toIso8601String(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Итог работы с ОДНИМ номером — единственный на всю кампанию (Т84).
|
||||
@@ -93,6 +95,39 @@ class ObzvonNumberResult extends Model
|
||||
|
||||
protected $table = 'obzvon_number_results';
|
||||
|
||||
/**
|
||||
* 🔴 ПОЛ ПОД Т84, а не вторая дверь.
|
||||
*
|
||||
* ОТ КАКОГО ВРЕДА. Правило «недозвон не затирает состоявшийся разговор»
|
||||
* живёт в двери `App\Services\Obzvon\ItogPoNomeru`. Но дверью можно не
|
||||
* воспользоваться: у модели есть поле `outcome`, и написать в него напрямую
|
||||
* — `$itog->outcome = 'no_answer'; $itog->save();` — проще, чем найти дверь.
|
||||
* Написавший так не сделает ничего внешне неправильного: столбец один,
|
||||
* значение из восьми разрешённых, база примет. Разговор затрётся МОЛЧА.
|
||||
*
|
||||
* 🔑 Это «голый шов»: две честные половины, между которыми ничто не
|
||||
* заставляет ходить. Сегодня итог по номеру не пишет НИКТО — модуль ещё не
|
||||
* звонит, — и значит первым пишущим будет следующая смена, которая станет
|
||||
* принимать отчёт робота. Она увидит поле раньше, чем дверь.
|
||||
*
|
||||
* Поэтому запрет стоит там, где его нельзя обойти незнанием: на самой
|
||||
* записи. Это не белый список «кому можно» — списка нет вовсе, и дверь
|
||||
* никаких поблажек не имеет. Проверяется СМЫСЛ перехода, а не автор.
|
||||
*
|
||||
* 🪤 ЧЕГО ЭТОТ ПОЛ НЕ ЛОВИТ, называю сразу: массовую правку
|
||||
* `...->update([...])` и запросы `DB::table(...)` — Eloquent на них событий
|
||||
* НЕ шлёт. Их стережёт отдельная проверка
|
||||
* `tests/Feature/Obzvon/ObzvonZapisMimoDveriTest.php`. Ни то, ни другое не
|
||||
* ловит правку прямым SQL мимо портала — от неё спасёт только замок в самой
|
||||
* базе, а это правка канона схемы и решение владельца.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $itog): void {
|
||||
$itog->proveritPolT84();
|
||||
});
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'campaign_id',
|
||||
@@ -143,4 +178,104 @@ class ObzvonNumberResult extends Model
|
||||
{
|
||||
return $this->hasMany(ObzvonCall::class, 'number_result_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Единственное, что запрещено безусловно: положить исход «про попытку»
|
||||
* поверх исхода «про номер». Всё остальное — дело двери.
|
||||
*
|
||||
* Разбор случаев, и каждый нарочно пропускается:
|
||||
* • исход не менялся — не наш случай;
|
||||
* • новый исход пуст — это обезличивание, стирать право есть;
|
||||
* • прежний исход пуст — первая запись, затирать нечего. Двенадцать
|
||||
* недозвонов подряд обязаны дать «не дозвонились», и они его дают;
|
||||
* • прежний исход тоже «про попытку» — недозвон поверх недозвона, обычный
|
||||
* ход счётчика попыток.
|
||||
* Остаётся ровно один запрещённый переход, и на нём — падение с именем.
|
||||
*
|
||||
* 🔴 ПОЧЕМУ ПРАВИЛО ЗДЕСЬ ПОВТОРЕНО, а не позвано у RaskladkaIskhodov.
|
||||
* `App\Models\*` не вправе зависеть от `App\Services\*`: в `deptrac.yaml`
|
||||
* у слоя Model разрешённых зависимостей НОЛЬ, и правка этого файла не в
|
||||
* моей власти. Тот же случай уже описан у `ObzvonCall::tsenaRubli`, и
|
||||
* лечится он там же тем же способом: **два места связаны проверкой**, а не
|
||||
* оставлены на удачу. Разойдутся — покраснеет
|
||||
* `tests/Feature/Obzvon/ObzvonZapisMimoDveriTest.php`, который обходит все
|
||||
* 64 пары исходов и требует, чтобы пол и дверь отвечали одинаково.
|
||||
*
|
||||
* Сама раскладка НЕ дублируется: и пол, и дверь читают один и тот же
|
||||
* `config('obzvon.raskladka')`. Повторено только применение правила.
|
||||
*/
|
||||
private function proveritPolT84(): void
|
||||
{
|
||||
if (! $this->isDirty('outcome')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$novyy = $this->outcome;
|
||||
$staryy = $this->getOriginal('outcome');
|
||||
|
||||
if (! is_string($novyy) || ! is_string($staryy)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! self::iskhodProPopytku($novyy) || self::iskhodProPopytku($staryy)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException(
|
||||
'Запрещено требованием Т84: исход «'.$novyy.'» это исход попытки, '
|
||||
.'и он не может лечь поверх состоявшегося разговора «'.$staryy.'» '
|
||||
.'у номера '.$this->phone.'. Записывать исход можно только через '
|
||||
.'App\Services\Obzvon\ItogPoNomeru::zapisat — он сам решит, '
|
||||
.'перебивает новый исход прежний или проходит бесследно.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Раскладка берётся из настройки — того же места, откуда её берёт дверь.
|
||||
* Своего списка у модели нет: два списка однажды разъедутся.
|
||||
*
|
||||
* Открыт наружу, потому что тем же ответом пользуется пол для массовой
|
||||
* правки — `ObzvonNumberResultBuilder`. Два пола, одно чтение настройки.
|
||||
*/
|
||||
public static function iskhodProPopytku(string $outcome): bool
|
||||
{
|
||||
return in_array($outcome, self::spisokIskhodov('pro_popytku'), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Исходы «про номер» — те, что означают состоявшийся разговор.
|
||||
*
|
||||
* Считается ВЫЧИТАНИЕМ из восьми, а не вторым списком в настройке: если
|
||||
* кто-то допишет девятый исход и забудет разложить его по родам, он
|
||||
* окажется «про номер», то есть под защитой, а не мимо неё. Ошибаться
|
||||
* следует в сторону охраны.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function iskhodyProNomer(): array
|
||||
{
|
||||
$proPopytku = self::spisokIskhodov('pro_popytku');
|
||||
|
||||
return array_values(array_diff(self::OUTCOMES, $proPopytku));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function spisokIskhodov(string $rod): array
|
||||
{
|
||||
/** @var array<int, mixed> $znacheniya */
|
||||
$znacheniya = (array) config('obzvon.raskladka.'.$rod, []);
|
||||
|
||||
return array_values(array_filter($znacheniya, 'is_string'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Все запросы к итогам идут через свой построитель — в нём стоит пол под
|
||||
* массовую правку, включая запись через связь.
|
||||
*
|
||||
* @param Builder $query
|
||||
*/
|
||||
public function newEloquentBuilder($query): ObzvonNumberResultBuilder
|
||||
{
|
||||
return new ObzvonNumberResultBuilder($query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Пол под Т84 для МАССОВОЙ правки итогов по номеру.
|
||||
*
|
||||
* 🔴 ОТ КАКОГО ВРЕДА, и почему пола в самой модели оказалось мало. Пол в
|
||||
* `ObzvonNumberResult::booted` стоит на событии сохранения ОБЪЕКТА. А запись
|
||||
* можно сделать, объекта не создавая:
|
||||
*
|
||||
* $sdelka->obzvonItogi()->update(['outcome' => 'no_answer']);
|
||||
*
|
||||
* Это построитель запросов, событий сохранения он не шлёт, и пол молчал бы.
|
||||
* Хуже того — в этом выражении **нет ни имени класса, ни имени таблицы**,
|
||||
* поэтому и обход кода по этим именам его не находил: замерено ножом
|
||||
* надзирателя 06.08.2026, сторож дал 8 из 8 зелёных при живой дыре.
|
||||
*
|
||||
* 🔑 И это самый вероятный способ записи из всех. Связи `Deal → итоги обзвона`
|
||||
* сегодня нет, но первое, что сделает смена, доводящая обзвон до сервера, —
|
||||
* заведёт её. Тогда самый естественный способ записи оказался бы единственным,
|
||||
* которого никто не сторожит.
|
||||
*
|
||||
* Здесь запрет стоит на самом построителе Eloquent. Он ловит все три формы
|
||||
* сразу, потому что все они идут через `newEloquentBuilder`:
|
||||
* • `ObzvonNumberResult::where(...)->update([...])`;
|
||||
* • `$sdelka->obzvonItogi()->update([...])` — через связь;
|
||||
* • `$itog->newQuery()->update([...])`.
|
||||
* Имён переменных он не читает вовсе — стоит на самой записи.
|
||||
*
|
||||
* 🪤 ЧЕГО НЕ ЛОВИТ И ЭТОТ ПОЛ: `DB::table('obzvon_number_results')->update(...)`
|
||||
* — это построитель запросов Laravel, а не Eloquent, и он проходит мимо. Его
|
||||
* стережёт обход кода `tests/Feature/Obzvon/ObzvonZapisMimoDveriTest.php`.
|
||||
* Полного покрытия не даёт ни один из трёх — см. перечень в шапке того файла.
|
||||
*
|
||||
* Белого списка «кому можно» здесь нет: проверяется СМЫСЛ перехода, а не автор.
|
||||
*
|
||||
* @extends Builder<ObzvonNumberResult>
|
||||
*/
|
||||
class ObzvonNumberResultBuilder extends Builder
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $values
|
||||
*/
|
||||
public function update(array $values): int
|
||||
{
|
||||
$this->proveritPolT84($values);
|
||||
|
||||
return parent::update($values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Запрещён ровно один переход: исход «про попытку» поверх строки, которая
|
||||
* уже держит исход «про номер». Всё остальное пропускается.
|
||||
*
|
||||
* 🔴 Считаем ПО ФАКТУ, а не по намерению: спрашиваем базу, есть ли среди
|
||||
* строк под правку хоть одна с состоявшимся разговором. Массовая правка
|
||||
* тем и опасна, что пишущий не знает, что лежит в каждой строке, — значит
|
||||
* знать обязан пол.
|
||||
*
|
||||
* @param array<string, mixed> $values
|
||||
*/
|
||||
private function proveritPolT84(array $values): void
|
||||
{
|
||||
if (! array_key_exists('outcome', $values)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$novyy = $values['outcome'];
|
||||
|
||||
// Очистка исхода — право на обезличивание сильнее Т84.
|
||||
if (! is_string($novyy)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! ObzvonNumberResult::iskhodProPopytku($novyy)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$proNomer = ObzvonNumberResult::iskhodyProNomer();
|
||||
|
||||
if ($proNomer === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$zatryot = (clone $this)->whereIn('outcome', $proNomer)->count();
|
||||
|
||||
if ($zatryot === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException(
|
||||
'Запрещено требованием Т84: массовая правка ставит исход попытки «'.$novyy
|
||||
.'» поверх состоявшегося разговора у '.$zatryot.' строк. Записывать исход '
|
||||
.'можно только через App\Services\Obzvon\ItogPoNomeru::zapisat — он решает '
|
||||
.'по каждому номеру отдельно, перебивает новый исход прежний или проходит '
|
||||
.'бесследно.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -155,9 +155,25 @@ class AutopodborServiceProvider extends ServiceProvider
|
||||
endpoint: (string) config('services.self_render.endpoint', ''),
|
||||
key: (string) config('services.self_render.key', ''),
|
||||
concurrency: (int) config('services.self_render.concurrency', 8),
|
||||
razreshyonnyeHosty: self::razreshyonnyeHosty(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 Р126: куда разрешено ходить с тайным ключом рендера. Общий список на обе двери —
|
||||
* {@see SelfRenderClient} и {@see RenderServiceYandexListSource} ходят по ОДНОМУ адресу
|
||||
* и с ОДНИМ ключом, значит и список у них обязан быть один.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function razreshyonnyeHosty(): array
|
||||
{
|
||||
/** @var array<array-key, mixed> $hosty */
|
||||
$hosty = (array) config('services.self_render.allowed_hosts', []);
|
||||
|
||||
return array_values(array_map(strval(...), $hosty));
|
||||
}
|
||||
|
||||
/** 2ГИС-ридер под текущий флаг: initialState-JSON (self-render) ИЛИ HTML (xf4). Пара к фетчеру. */
|
||||
public function twoGisReader(): TwoGisReader
|
||||
{
|
||||
@@ -171,6 +187,7 @@ class AutopodborServiceProvider extends ServiceProvider
|
||||
return new RenderServiceYandexListSource(
|
||||
(string) config('services.self_render.endpoint', ''),
|
||||
(string) config('services.self_render.key', ''),
|
||||
razreshyonnyeHosty: self::razreshyonnyeHosty(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Admin;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Лента ДЕЛ клиента: что он сам изменил в кабинете.
|
||||
*
|
||||
* Системного шума здесь нет и быть не должно. Замерено 07.08.2026 на боевом:
|
||||
* из 419 строк журнала сделок 364 — это робот-маршрутизатор («залил лид»),
|
||||
* и именно они забивали старую вкладку. Отбор строится наоборот — берём
|
||||
* только то, за чем стоит человек.
|
||||
*
|
||||
* Правка баланса админом (manual_adjustment) тоже не идёт: она выглядела бы
|
||||
* как шевеление клиента, которого не было.
|
||||
*
|
||||
* Объединяющий SQL живёт здесь в единственном экземпляре: и лента, и счётчик
|
||||
* дел за период обязаны отбирать одно и то же, иначе цифра «действий» разойдётся
|
||||
* с тем, что видно ниже на экране.
|
||||
*/
|
||||
final class TenantActivityFeedService
|
||||
{
|
||||
/**
|
||||
* @return array<int, array{
|
||||
* id: string, at: string, kind: string, event: string,
|
||||
* actor: string|null, subject: string|null, amount_rub: string|null, bulk_count: int
|
||||
* }>
|
||||
*/
|
||||
public function forTenant(int $tenantId, int $limit, int $offset): array
|
||||
{
|
||||
$sql = 'SELECT * FROM ('.$this->unionSql().') f ORDER BY f.at DESC, f.id DESC LIMIT ? OFFSET ?';
|
||||
|
||||
$rows = DB::select($sql, [...$this->unionBindings($tenantId), $limit, $offset]);
|
||||
|
||||
return array_map(fn ($r) => [
|
||||
'id' => (string) $r->id,
|
||||
'at' => CarbonImmutable::parse($r->at)->toIso8601String(),
|
||||
'kind' => (string) $r->kind,
|
||||
'event' => (string) $r->event,
|
||||
'actor' => $r->actor !== null ? (string) $r->actor : null,
|
||||
'subject' => $r->subject !== null ? (string) $r->subject : null,
|
||||
'amount_rub' => $r->amount_rub !== null ? (string) $r->amount_rub : null,
|
||||
'bulk_count' => (int) $r->bulk_count,
|
||||
], $rows);
|
||||
}
|
||||
|
||||
/** Сколько дел у клиента с указанного мгновения. Тот же отбор, что у ленты. */
|
||||
public function countSince(int $tenantId, DateTimeInterface $since): int
|
||||
{
|
||||
$sql = 'SELECT count(*) AS c FROM ('.$this->unionSql().') f WHERE f.at >= ?';
|
||||
|
||||
$rows = DB::select($sql, [...$this->unionBindings($tenantId), $since]);
|
||||
|
||||
return (int) ($rows[0]->c ?? 0);
|
||||
}
|
||||
|
||||
/** Есть ли ещё страница после выданной. */
|
||||
public function hasMore(int $tenantId, int $limit, int $offset): bool
|
||||
{
|
||||
$sql = 'SELECT 1 FROM ('.$this->unionSql().') f ORDER BY f.at DESC, f.id DESC LIMIT 1 OFFSET ?';
|
||||
|
||||
return DB::select($sql, [...$this->unionBindings($tenantId), $limit + $offset]) !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Пять источников с одинаковым набором колонок.
|
||||
* Порядок подстановок: пять раз tenant_id — по одному на каждый кусок.
|
||||
*/
|
||||
private function unionSql(): string
|
||||
{
|
||||
return <<<'SQL'
|
||||
SELECT 'ops-' || o.id AS id, o.created_at AS at, 'project' AS kind, o.event AS event,
|
||||
u.email AS actor,
|
||||
COALESCE(p.name, o.payload_after->>'name', o.payload_before->>'name') AS subject,
|
||||
NULL::text AS amount_rub,
|
||||
CASE WHEN jsonb_typeof(o.payload_after->'ids') = 'array'
|
||||
THEN jsonb_array_length(o.payload_after->'ids') ELSE 0 END AS bulk_count
|
||||
FROM tenant_operations_log o
|
||||
LEFT JOIN users u ON u.id = o.user_id
|
||||
LEFT JOIN projects p ON p.id = o.entity_id AND o.entity_type = 'project'
|
||||
WHERE o.tenant_id = ?
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'act-' || a.id, a.created_at, 'deal', a.event,
|
||||
u.email, a.deal_id::text, NULL::text, 0
|
||||
FROM activity_log a
|
||||
LEFT JOIN users u ON u.id = a.user_id
|
||||
WHERE a.tenant_id = ? AND a.user_id IS NOT NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'tx-' || b.id, b.created_at, 'money', 'balance.topup',
|
||||
u.email, NULL::text, b.amount_rub::text, 0
|
||||
FROM balance_transactions b
|
||||
LEFT JOIN users u ON u.id = b.user_id
|
||||
WHERE b.tenant_id = ? AND b.type = 'topup'
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'inv-' || i.id || '-issued', i.issued_at, 'money', 'invoice.issued',
|
||||
i.payer_email, i.invoice_number, i.amount_total::text, 0
|
||||
FROM saas_invoices i
|
||||
WHERE i.tenant_id = ? AND i.issued_at IS NOT NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'inv-' || i.id || '-paid', i.paid_at, 'money', 'invoice.paid',
|
||||
i.payer_email, i.invoice_number, i.amount_total::text, 0
|
||||
FROM saas_invoices i
|
||||
WHERE i.tenant_id = ? AND i.paid_at IS NOT NULL
|
||||
SQL;
|
||||
}
|
||||
|
||||
/** @return array<int, int> */
|
||||
private function unionBindings(int $tenantId): array
|
||||
{
|
||||
return [$tenantId, $tenantId, $tenantId, $tenantId, $tenantId];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Admin;
|
||||
|
||||
use App\Support\MskBoundary;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Отвечает на единственный вопрос админки: жив ли клиент — заходит он или пропал.
|
||||
*
|
||||
* Считает на лету из auth_log. В базу ничего не пишет: колонки
|
||||
* tenants.last_activity_at и users.last_active_at никем не заполняются
|
||||
* (замерено 07.08.2026 — ноль из одиннадцати), полагаться на них нельзя.
|
||||
*
|
||||
* 🔴 Сутки считаются МОСКОВСКИЕ. Гринвичская полночь — это три часа ночи по
|
||||
* Москве; на наивной границе в проекте уже обжигались (см. App\Support\MskBoundary).
|
||||
*/
|
||||
final class TenantAlivenessService
|
||||
{
|
||||
/** Сутки без входа, после которых клиент считается остывающим. */
|
||||
public const COOLING_AFTER_DAYS = 4;
|
||||
|
||||
/** Сутки без входа, после которых клиент считается пропавшим. */
|
||||
public const LOST_AFTER_DAYS = 14;
|
||||
|
||||
/** Глубина полоски. */
|
||||
public const STRIP_DAYS = 90;
|
||||
|
||||
/** Окно счётчиков. */
|
||||
public const COUNTERS_DAYS = 30;
|
||||
|
||||
/** События, которые считаются визитом. */
|
||||
private const VISIT_EVENTS = ['login_success', 'register_success'];
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantActivityFeedService $feed = new TenantActivityFeedService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* state: string,
|
||||
* last_login_at: string|null,
|
||||
* days_since_login: int|null,
|
||||
* last_login_ip: string|null,
|
||||
* last_login_user_agent: string|null,
|
||||
* days: array<int, array{date: string, visits: int}>,
|
||||
* counters: array{visits_30d: int, active_days_30d: int, actions_30d: int, failed_logins_30d: int}
|
||||
* }
|
||||
*/
|
||||
public function forTenant(int $tenantId, ?CarbonImmutable $now = null): array
|
||||
{
|
||||
$now ??= CarbonImmutable::now('Europe/Moscow');
|
||||
$now = $now->setTimezone('Europe/Moscow');
|
||||
|
||||
$lastLogin = $this->lastVisit($tenantId, 'login_success');
|
||||
$hasAnyRow = $this->hasAnyRow($tenantId);
|
||||
|
||||
$daysSince = $lastLogin !== null
|
||||
? $this->fullMoscowDaysBetween((string) $lastLogin->created_at, $now)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'state' => $this->deriveState($daysSince, $hasAnyRow),
|
||||
'last_login_at' => $lastLogin !== null
|
||||
? CarbonImmutable::parse($lastLogin->created_at)->toIso8601String()
|
||||
: null,
|
||||
'days_since_login' => $daysSince,
|
||||
'last_login_ip' => $lastLogin->ip_address ?? null,
|
||||
'last_login_user_agent' => $lastLogin->user_agent ?? null,
|
||||
'days' => $this->visitDays($tenantId, $now),
|
||||
'counters' => $this->counters($tenantId, $now),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Последняя строка нужного события. Без ограничения по окну: LIMIT 1 по
|
||||
* индексу idx_auth_log_tenant_user позволяет Postgres обойти месячные
|
||||
* куски журнала от свежего к старому и остановиться на первом попадании.
|
||||
*/
|
||||
private function lastVisit(int $tenantId, string $event): ?object
|
||||
{
|
||||
return DB::table('auth_log')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('event', $event)
|
||||
->orderByDesc('created_at')
|
||||
->limit(1)
|
||||
->first(['created_at', 'ip_address', 'user_agent']);
|
||||
}
|
||||
|
||||
private function hasAnyRow(int $tenantId): bool
|
||||
{
|
||||
return DB::table('auth_log')->where('tenant_id', $tenantId)->limit(1)->exists();
|
||||
}
|
||||
|
||||
/** Полных московских суток между днём события и сегодняшним днём. */
|
||||
private function fullMoscowDaysBetween(string $eventAt, CarbonImmutable $now): int
|
||||
{
|
||||
$eventDay = CarbonImmutable::parse($eventAt)->setTimezone('Europe/Moscow')->startOfDay();
|
||||
$today = $now->startOfDay();
|
||||
|
||||
return (int) $eventDay->diffInDays($today);
|
||||
}
|
||||
|
||||
private function deriveState(?int $daysSince, bool $hasAnyRow): string
|
||||
{
|
||||
if ($daysSince === null) {
|
||||
return $hasAnyRow ? 'never_logged_in' : 'no_data';
|
||||
}
|
||||
if ($daysSince >= self::LOST_AFTER_DAYS) {
|
||||
return 'lost';
|
||||
}
|
||||
if ($daysSince >= self::COOLING_AFTER_DAYS) {
|
||||
return 'cooling';
|
||||
}
|
||||
|
||||
return 'active';
|
||||
}
|
||||
|
||||
/** @return array<int, array{date: string, visits: int}> */
|
||||
private function visitDays(int $tenantId, CarbonImmutable $now): array
|
||||
{
|
||||
$since = MskBoundary::instant($now->subDays(self::STRIP_DAYS - 1)->startOfDay());
|
||||
|
||||
$rows = DB::table('auth_log')
|
||||
->selectRaw("(created_at AT TIME ZONE 'Europe/Moscow')::date AS d, count(*) AS visits")
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('event', self::VISIT_EVENTS)
|
||||
->where('created_at', '>=', $since)
|
||||
->groupBy('d')
|
||||
->orderBy('d')
|
||||
->get();
|
||||
|
||||
return $rows->map(fn ($r) => [
|
||||
'date' => (string) $r->d,
|
||||
'visits' => (int) $r->visits,
|
||||
])->all();
|
||||
}
|
||||
|
||||
/** @return array{visits_30d: int, active_days_30d: int, actions_30d: int, failed_logins_30d: int} */
|
||||
private function counters(int $tenantId, CarbonImmutable $now): array
|
||||
{
|
||||
$since = MskBoundary::instant($now->subDays(self::COUNTERS_DAYS - 1)->startOfDay());
|
||||
|
||||
$visitList = "'".implode("','", self::VISIT_EVENTS)."'";
|
||||
|
||||
$row = DB::table('auth_log')
|
||||
->selectRaw("
|
||||
count(*) FILTER (WHERE event IN ({$visitList})) AS visits,
|
||||
count(DISTINCT (created_at AT TIME ZONE 'Europe/Moscow')::date)
|
||||
FILTER (WHERE event IN ({$visitList})) AS active_days,
|
||||
count(*) FILTER (WHERE event = 'login_failed') AS failed_logins
|
||||
")
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('created_at', '>=', $since)
|
||||
->first();
|
||||
|
||||
return [
|
||||
'visits_30d' => (int) ($row->visits ?? 0),
|
||||
'active_days_30d' => (int) ($row->active_days ?? 0),
|
||||
'actions_30d' => $this->feed->countSince($tenantId, $since),
|
||||
'failed_logins_30d' => (int) ($row->failed_logins ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Fetch;
|
||||
|
||||
use App\Services\Obzvon\ObzvonClient;
|
||||
|
||||
/**
|
||||
* 🔴 Р126 — ДВЕРЬ К РЕНДЕР-ВИРТУАЛКЕ. Решение владельца от 07.08.2026.
|
||||
*
|
||||
* Тайный ключ `SELF_RENDER_KEY` едет ЗАГОЛОВКОМ `X-Render-Key` в КАЖДОМ запросе к виртуалке.
|
||||
* До этой двери проверялось ровно одно: «адрес не пуст и ключ не пуст». Отсюда две беды, и обе
|
||||
* молчаливые:
|
||||
*
|
||||
* 1. **Опечатка в `SELF_RENDER_ENDPOINT` увела бы ключ чужому человеку** — навсегда и без
|
||||
* единого признака: запрос ушёл бы, ответ не подошёл бы, поиск отдал бы пусто, а ключ
|
||||
* остался бы у того, кто принял запрос.
|
||||
* 2. **`http://` увёз бы ключ открытым текстом** — по той же опечатке в одной букве.
|
||||
*
|
||||
* Устройство повторяет дверь к голосовому роботу ({@see ObzvonClient}, Р123),
|
||||
* уже проверенную приёмкой на восьми формах адреса. Порядок проверок тот же и он важен: сперва
|
||||
* «есть ли что проверять», потом КУДА, и только потом КАК.
|
||||
*
|
||||
* 🔴 Тумблера «можно без шифра» здесь нет намеренно. Такой тумблер и был бы той самой опечаткой,
|
||||
* ради которой дверь заведена.
|
||||
*
|
||||
* 🪤 Чего дверь НЕ закрывает, говорю прямо: шифрование бережёт ключ В ПУТИ и разрешённый хост
|
||||
* бережёт от опечатки, но НИ ТО НИ ДРУГОЕ не доказывает, что на том конце наша виртуалка, а не
|
||||
* чужая машина, занявшая тот же адрес. Это отдельная задача (закрепить отпечаток сертификата),
|
||||
* и она не здесь.
|
||||
*/
|
||||
final class DverRendera
|
||||
{
|
||||
private readonly string $endpoint;
|
||||
|
||||
/** @var list<string> */
|
||||
private readonly array $razreshyonnyeHosty;
|
||||
|
||||
/**
|
||||
* @param list<string> $razreshyonnyeHosty хосты, куда вообще разрешено ходить с тайным ключом
|
||||
*/
|
||||
public function __construct(string $endpoint, private readonly string $key, array $razreshyonnyeHosty = [])
|
||||
{
|
||||
$this->endpoint = trim($endpoint);
|
||||
$this->razreshyonnyeHosty = array_values(array_filter(
|
||||
array_map(static fn (string $h): string => trim($h), $razreshyonnyeHosty),
|
||||
static fn (string $h): bool => $h !== '',
|
||||
));
|
||||
}
|
||||
|
||||
/** Адрес без пробелов по краям — им же и ходим, чтобы проверяли и звали ОДНО И ТО ЖЕ. */
|
||||
public function adres(): string
|
||||
{
|
||||
return $this->endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пустая строка = дверь открыта. Иначе — человеческая причина, почему закрыта.
|
||||
* Каждая причина СВОИМИ словами: «не задан адрес» и «не задан ключ» — разные беды.
|
||||
*/
|
||||
public function pochemuZakryta(): string
|
||||
{
|
||||
if ($this->endpoint === '') {
|
||||
return 'не задан адрес рендер-сервиса';
|
||||
}
|
||||
|
||||
if (trim($this->key) === '') {
|
||||
return 'не задан тайный ключ рендер-сервиса';
|
||||
}
|
||||
|
||||
$host = (string) (parse_url($this->endpoint, PHP_URL_HOST) ?: '');
|
||||
|
||||
if ($host === '') {
|
||||
return 'адрес рендер-сервиса не разобрать — в нём нет хоста';
|
||||
}
|
||||
|
||||
// 🔴 Пустой список = закрыто ВСЁ: «раз не сказано куда можно — значит никуда», а не
|
||||
// наоборот. Ошибка в сторону закрытой двери стоит пустого поиска; ошибка в другую
|
||||
// сторону стоит тайного ключа, и её уже не отыграть.
|
||||
if (! in_array(mb_strtolower($host), array_map('mb_strtolower', $this->razreshyonnyeHosty), true)) {
|
||||
return 'адрес рендер-сервиса ведёт на неразрешённый хост '.$host;
|
||||
}
|
||||
|
||||
$shema = mb_strtolower((string) (parse_url($this->endpoint, PHP_URL_SCHEME) ?: ''));
|
||||
|
||||
// 🔴 Разрешённый хост НЕ означает безопасную дорогу: `http://` к той же самой виртуалке
|
||||
// везёт тайный ключ открытым текстом.
|
||||
if ($shema !== 'https' && ! $this->svoyaMashina($host)) {
|
||||
return 'адрес рендер-сервиса без шифрования ('
|
||||
.($shema === '' ? 'схема не указана' : $shema.'://')
|
||||
.') — тайный ключ уехал бы открытым текстом';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 Единственное исключение: разработчик на СВОЁМ компьютере.
|
||||
*
|
||||
* Определяется не списком разрешённых слов (такой список сам становится дверью), а ДВУМЯ
|
||||
* независимыми ответами, и оба обязаны совпасть:
|
||||
*
|
||||
* 1. **Где живёт само приложение** — спрашиваем Laravel (`APP_ENV`). Список положительный:
|
||||
* любое окружение, которого тут не назвали (боевое, витрина, что заведут завтра),
|
||||
* шифрование обязано.
|
||||
* 2. **Где стоит виртуалка** — адрес обязан вести на петлю ЭТОЙ ЖЕ машины: тогда запрос
|
||||
* физически не покидает компьютер и подслушивать его негде.
|
||||
*
|
||||
* Зачем два, а не один: одной перепутанной настройки на боевом для утечки не хватает —
|
||||
* боевая виртуалка живёт на чужом железе и петлёй её адрес не станет.
|
||||
*/
|
||||
private function svoyaMashina(string $host): bool
|
||||
{
|
||||
if (! app()->environment('local', 'testing')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->petlya($host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ведёт ли адрес на петлю этой самой машины — по существу, а не по написанию.
|
||||
*
|
||||
* 🪤 Резолвер имён сюда намеренно не зовём: он превратил бы проверку безопасности в
|
||||
* неопределённую (чужой медленный DNS вешал бы поиск) и открыл бы щель между «проверили» и
|
||||
* «пошли» — имя, ответившее петлёй на проверке, к моменту запроса может отвечать чужим
|
||||
* адресом. Разработчику дверь скажет причину вслух, лечится одной правкой на `127.0.0.1`.
|
||||
*/
|
||||
private function petlya(string $host): bool
|
||||
{
|
||||
// Квадратные скобки — как IPv6 пишется в адресе: http://[::1]:8790/…
|
||||
$host = trim(mb_strtolower(trim($host)), '[]');
|
||||
|
||||
// RFC 6761 §6.3: `localhost` и всё под `.localhost` закреплены за петлёй этой машины
|
||||
// СТАНДАРТОМ. 🪤 `localhost.chuzhoy.ru` сюда не попадает: сверяется конец имени, а не
|
||||
// наличие слова внутри.
|
||||
if ($host === 'localhost' || str_ends_with($host, '.localhost')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dvoichnyy = inet_pton($host);
|
||||
|
||||
if (! is_string($dvoichnyy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ::ffff:127.0.0.1 — та же петля, записанная по-шестому. Сравнение ДВОИЧНОЕ, а не по словам.
|
||||
if (strlen($dvoichnyy) === 16 && str_starts_with($dvoichnyy, str_repeat("\0", 10)."\xff\xff")) {
|
||||
$dvoichnyy = substr($dvoichnyy, 12);
|
||||
}
|
||||
|
||||
// Петля — это ВСЯ сеть 127.0.0.0/8, а не один 127.0.0.1: у разработчика сервис может
|
||||
// стоять на 127.0.1.1 (Debian) или 127.0.0.53 (resolved).
|
||||
if (strlen($dvoichnyy) === 4) {
|
||||
return $dvoichnyy[0] === "\x7f";
|
||||
}
|
||||
|
||||
return $dvoichnyy === str_repeat("\0", 15)."\x01";
|
||||
}
|
||||
}
|
||||
@@ -5,27 +5,59 @@ declare(strict_types=1);
|
||||
namespace App\Services\Autopodbor\Agent\Fetch;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* self-render путь {@see YandexListSource}: просит виртуалку отрисовать ленту Яндекс.Карт (со скроллом)
|
||||
* через прокси и вернуть организации. Контракт: `POST {endpoint} {url,type:'yandex-list',scrolls}` с
|
||||
* заголовком `X-Render-Key` → `{ "data": "<JSON {orgs:[...]}>" }`. Без endpoint/key — пусто (деградация).
|
||||
*
|
||||
* 🔴 Р126. Это ВТОРАЯ дверь на том же тайном ключе и том же `SELF_RENDER_ENDPOINT`, что и
|
||||
* {@see SelfRenderClient}. Починить только одну из них — значит не починить ничего: одна и та же
|
||||
* опечатка в адресе увела бы ключ отсюда ровно так же. Дверь — общая, {@see DverRendera}.
|
||||
*
|
||||
* 🔴 Р128. По той же причине здесь стоит и запрет ходить за переадресацией: дверь проверяет
|
||||
* адрес из настройки, а второй адрес прилетает ответом, и о нём она не знает ничего. Подробно —
|
||||
* в шапке {@see SelfRenderClient}.
|
||||
*/
|
||||
final class RenderServiceYandexListSource implements YandexListSource
|
||||
{
|
||||
private readonly string $endpoint;
|
||||
|
||||
private readonly DverRendera $dver;
|
||||
|
||||
/**
|
||||
* @param list<string> $razreshyonnyeHosty пустой список = закрыто ВСЁ (fail-closed)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $endpoint,
|
||||
string $endpoint,
|
||||
private readonly string $key,
|
||||
private readonly int $httpTimeout = 120,
|
||||
) {}
|
||||
array $razreshyonnyeHosty = [],
|
||||
) {
|
||||
$this->dver = new DverRendera($endpoint, $key, $razreshyonnyeHosty);
|
||||
$this->endpoint = $this->dver->adres();
|
||||
}
|
||||
|
||||
public function fetch(string $url, int $scrolls): array
|
||||
{
|
||||
if ($this->endpoint === '' || $this->key === '') {
|
||||
$zakryta = $this->dver->pochemuZakryta();
|
||||
|
||||
if ($zakryta !== '') {
|
||||
// Мягкая деградация: лента Яндекса пуста, наружу не ходили, ключ никуда не ушёл.
|
||||
// Уровень `warning` — на боевом всё ниже в журнал не доезжает.
|
||||
Log::warning('поиск клиентов: дверь к рендер-виртуалке закрыта — '.$zakryta, [
|
||||
'ruchka' => 'yandex-list',
|
||||
'adres' => $this->endpoint,
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$resp = Http::timeout($this->httpTimeout)
|
||||
// 🔴 Р128: за переадресацией не идём — иначе разрешённая машина одним ответом
|
||||
// «иди туда» увезла бы тайный ключ на машину, которой дверь не проверяла.
|
||||
->withoutRedirecting()
|
||||
->withHeaders(['X-Render-Key' => $this->key])
|
||||
->asJson()
|
||||
->post($this->endpoint, ['url' => $url, 'type' => 'yandex-list', 'scrolls' => $scrolls]);
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Services\Autopodbor\Agent\Fetch;
|
||||
use Illuminate\Http\Client\Pool;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* HTTP-клиент к своему рендер-сервису на виртуалке (замена xf4). По форме — как {@see XfetchClient}:
|
||||
@@ -16,16 +17,45 @@ use Illuminate\Support\Facades\Http;
|
||||
* Без ключа/адреса — молча пусто (деградация, как xfetch без ключа), поток не падает.
|
||||
*
|
||||
* Контракт ответа виртуалки: `{ "data": "<initialState|html>" }`; не-2xx/нет data → пустая строка.
|
||||
*
|
||||
* 🔴 Р128 (07.08.2026): дверь проверяет адрес ИЗ НАСТРОЙКИ — и только его. Если разрешённая
|
||||
* машина ответит «иди на другой адрес», перевозчик по умолчанию пойдёт туда и увезёт тайный
|
||||
* ключ на машину, которой дверь в глаза не видела: при уходе на чужой адрес Guzzle снимает
|
||||
* ровно два заголовка, `Authorization` и `Cookie` (`RedirectMiddleware.php`, строки 199–202),
|
||||
* а `X-Render-Key` не из них. Поэтому за переадресацией мы НЕ ХОДИМ ВОВСЕ
|
||||
* (`withoutRedirecting()`) — ни в одиночном запросе, ни в пачке. Ответ 3xx после этого просто
|
||||
* не «успешный», то есть страница выйдет пустой, и поиск честно скажет, что данных нет.
|
||||
*
|
||||
* 🔴 Тумблера «а этой машине можно переадресовывать» здесь нет намеренно — он и был бы той
|
||||
* дырой, ради которой запрет поставлен. Замер 07.08.2026: боевая виртуалка по https не
|
||||
* переадресует вовсе (`/render` → 404, `/proxy-check` → 401), так что цены у запрета нет.
|
||||
*
|
||||
* 🔴 Р126 (07.08.2026): куда именно можно ходить с тайным ключом, решает {@see DverRendera} —
|
||||
* разрешённый хост + шифрованная дорога. Дверь закрыта = наружу НЕ ходим вовсе, отдаём пусто и
|
||||
* пишем причину в журнал уровнем `warning` (на боевом `LOG_LEVEL=warning`, всё ниже не доезжает
|
||||
* НИКОГДА — оплачено 04.08.2026). Это мягкая деградация, а не падение: поиск клиентов встаёт и
|
||||
* говорит об этом, но портал живёт.
|
||||
*/
|
||||
final class SelfRenderClient implements BatchPageFetcher
|
||||
{
|
||||
private readonly DverRendera $dver;
|
||||
|
||||
/**
|
||||
* @param list<string> $razreshyonnyeHosty хосты, куда разрешено ходить с тайным ключом.
|
||||
* Пустой список = закрыто ВСЁ (fail-closed).
|
||||
*/
|
||||
public function __construct(
|
||||
private string $endpoint,
|
||||
private string $key,
|
||||
private int $concurrency = 8,
|
||||
private int $httpTimeout = 120,
|
||||
private int $retries = 2,
|
||||
) {}
|
||||
array $razreshyonnyeHosty = [],
|
||||
) {
|
||||
$this->dver = new DverRendera($endpoint, $key, $razreshyonnyeHosty);
|
||||
// Проверяем и зовём ОДИН И ТОТ ЖЕ адрес — иначе между проверкой и запросом остаётся щель.
|
||||
$this->endpoint = $this->dver->adres();
|
||||
}
|
||||
|
||||
public function html(string $url): string
|
||||
{
|
||||
@@ -79,6 +109,10 @@ final class SelfRenderClient implements BatchPageFetcher
|
||||
$responses = Http::pool(fn (Pool $pool) => array_map(
|
||||
fn (string $u) => $pool->as($u)
|
||||
->timeout($this->httpTimeout)
|
||||
// 🔴 Тот же запрет, что и в {@see self::request()}, и поставлен он ЗДЕСЬ
|
||||
// отдельно намеренно: пачка строит каждый запрос сама, с нуля, и запрет из
|
||||
// одиночного пути сюда не доезжает. Дважды — потому что и утечёт дважды.
|
||||
->withoutRedirecting()
|
||||
->withHeaders(['X-Render-Key' => $this->key])
|
||||
->asJson()
|
||||
->post($this->endpoint, ['url' => $u, 'type' => $this->typeFor($u)]),
|
||||
@@ -94,6 +128,8 @@ final class SelfRenderClient implements BatchPageFetcher
|
||||
private function request(string $url): Response|\Throwable|null
|
||||
{
|
||||
return Http::timeout($this->httpTimeout)
|
||||
// 🔴 Р128: не ходим за переадресацией — см. шапку класса.
|
||||
->withoutRedirecting()
|
||||
->withHeaders(['X-Render-Key' => $this->key])
|
||||
->asJson()
|
||||
->post($this->endpoint, ['url' => $url, 'type' => $this->typeFor($url)]);
|
||||
@@ -116,8 +152,22 @@ final class SelfRenderClient implements BatchPageFetcher
|
||||
return is_string($data) ? $data : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Открыта ли дверь наружу. Закрыта — говорим об этом в журнал ОДИН раз на обращение и
|
||||
* никуда не идём. 🪤 В журнал уходят причина и адрес, но НЕ тайный ключ.
|
||||
*/
|
||||
private function ready(): bool
|
||||
{
|
||||
return $this->endpoint !== '' && $this->key !== '';
|
||||
$zakryta = $this->dver->pochemuZakryta();
|
||||
|
||||
if ($zakryta === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
Log::warning('поиск клиентов: дверь к рендер-виртуалке закрыта — '.$zakryta, [
|
||||
'adres' => $this->endpoint,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\ClientSms;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Окно часов, названное В СВОИХ ЧАСАХ какого-то одного места (задача З-3.1, Р115).
|
||||
*
|
||||
* 🔴 ЗАЧЕМ ЭТО ВООБЩЕ ЕСТЬ. Правило окна {@see SmsQuietHours} до сих пор знало
|
||||
* РОВНО ОДНО смещение — смещение получателя. Часы менеджера клиента живут в
|
||||
* ДРУГИХ часах: клиент называет их у себя, а получателя мы будим у него. Пока
|
||||
* смещение было одно, «добавить часы менеджера» означало бы сузить окно
|
||||
* получателя — и менеджер из Москвы, работающий 14:00–18:00, был бы прочитан как
|
||||
* «14:00–18:00 по Владивостоку». Робот перевёл бы звонок на спящего человека, а
|
||||
* на экране и в журнале всё бы сошлось. Поэтому второе окно приходит вместе со
|
||||
* СВОИМ смещением, а не одними часами.
|
||||
*
|
||||
* Здесь нет никакого расчёта — только три числа и проверка, что окно осмысленно.
|
||||
* Проверку делает то же самое {@see SmsQuietHours::isValidWindow()}: второго
|
||||
* ответа на вопрос «осмысленно ли окно» в портале быть не должно (строка Н.3).
|
||||
*
|
||||
* 🔴 Лежит рядом с правилом, а не в модуле обзвона, нарочно: это слово из
|
||||
* словаря самого правила. Обзвон его только заполняет (решение владельца Р76 —
|
||||
* правило одно, живой модуль правим, а не заводим правилу второй дом).
|
||||
*
|
||||
* Смещение — в МИНУТАХ от UTC, как во всём портале (`RegionTimezoneMap`): пояса
|
||||
* бывают и получасовыми, и час тут ничего не сэкономил бы.
|
||||
*/
|
||||
final class LocalHoursWindow
|
||||
{
|
||||
/**
|
||||
* @param int $offsetMinutes смещение места, в чьих часах названы `fromHour`/`toHour`
|
||||
* @param int $fromHour с какого часа окно открыто
|
||||
* @param int $toHour до какого часа окно открыто (сам этот час уже закрыт)
|
||||
*
|
||||
* @throws InvalidArgumentException если окно бессмысленно — например «с 20 до 10»
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $offsetMinutes,
|
||||
public readonly int $fromHour,
|
||||
public readonly int $toHour,
|
||||
) {
|
||||
if (! SmsQuietHours::isValidWindow($fromHour, $toHour)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Окно часов бессмысленно: с {$fromHour} до {$toHour}. "
|
||||
.'Окно «с 20 до 10» означало бы работу всю ночь, а не короткий день.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Для подписи на экране: «11:00–17:00». Часы, а не минуты — окно задаётся часами. */
|
||||
public function podpis(): string
|
||||
{
|
||||
return sprintf('%02d:00–%02d:00', $this->fromHour, $this->toHour);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Services\ClientSms;
|
||||
|
||||
use App\Models\ClientSmsSettings;
|
||||
use Carbon\CarbonImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Правило «отдаём сообщение оператору только с 10:00 до 20:00 по местному времени
|
||||
@@ -19,6 +20,38 @@ use Carbon\CarbonImmutable;
|
||||
*
|
||||
* Смещение пояса приходит в МИНУТАХ и снаружи — из снимка получателей. Сервис ничего
|
||||
* не знает ни про номера, ни про регионы: он про часы.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 🔴 ВТОРОЕ ОКНО — ЧАСЫ МЕНЕДЖЕРА КЛИЕНТА (задача З-3.1, решения владельца Р64,
|
||||
* Р76, Р115). Добавлено 07.08.2026.
|
||||
*
|
||||
* Обзвон спрашивает то же самое правило, но у него ДВА окна сразу: рамка по месту
|
||||
* ПОЛУЧАТЕЛЯ (не будить людей ночью) и график менеджера, названный клиентом в
|
||||
* СВОИХ часах. Набирать можно только там, где открыты оба — и «оба» считается в
|
||||
* общих часах, а не в часах одной стороны (Р115).
|
||||
*
|
||||
* 🔴 Почему так, а не «просто окно поуже»: сузив окно получателя часами
|
||||
* менеджера, мы прочитали бы московские 14:00–18:00 как 14:00–18:00 ПО
|
||||
* ВЛАДИВОСТОКУ и перевели бы звонок на спящего менеджера. Экран показал бы
|
||||
* «звоним 14–18», журнал — «звонок в 14:07», и всё бы сошлось. Второе окно
|
||||
* поэтому приходит вместе со своим смещением — {@see LocalHoursWindow}.
|
||||
*
|
||||
* 🔴 Второе окно только СУЖАЕТ рамку и никогда не расширяет (Р64): 09:00–22:00
|
||||
* принимается как 10:00–20:00. Сужение — {@see narrowedWindow()}.
|
||||
*
|
||||
* 🔴 Довод необязательный и по умолчанию пустой. Пустой довод — это сегодняшнее
|
||||
* поведение слово в слово: живой рассылке СМС никакого второго окна не задают, и
|
||||
* ни один её час не сдвигается. Доказано вычитанием, отчёт
|
||||
* `docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-z-3-1-okno-chasov-2026-08-07.md`.
|
||||
*
|
||||
* 🔴 Р76 отдал в общее пользование ПРАВИЛО, а не ЧИСЛА. Границы окна обзвона —
|
||||
* свои, из своей настройки (класс `ObzvonOknoChasov` модуля обзвона); сюда они
|
||||
* попадают через {@see withWindow()}, а не через `client_sms_settings`. Админ,
|
||||
* поправив окно рассылок, окно обзвона не двигает.
|
||||
*
|
||||
* Имя того класса здесь нарочно текстом, а не ссылкой: правило рассылки не должно
|
||||
* знать про модуль обзвона даже строкой импорта — зависимость идёт в одну сторону.
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
final class SmsQuietHours
|
||||
{
|
||||
@@ -41,13 +74,69 @@ final class SmsQuietHours
|
||||
return $fromHour >= 0 && $toHour <= 24 && $fromHour < $toHour;
|
||||
}
|
||||
|
||||
/** Можно ли отдавать сообщение прямо сейчас номеру с этим смещением. */
|
||||
public function canSendNow(int $offsetMinutes, ?CarbonImmutable $now = null): bool
|
||||
/**
|
||||
* Границы не из `client_sms_settings`, а названные снаружи — для тех, у кого
|
||||
* окно своё (обзвон, Р76: правило общее, числа разные).
|
||||
*
|
||||
* Запроса к базе такой объект не делает ни одного: обе границы уже известны,
|
||||
* и {@see load()} на них останавливается.
|
||||
*
|
||||
* @throws InvalidArgumentException если окно бессмысленно — например «с 20 до 10»
|
||||
*/
|
||||
public static function withWindow(int $fromHour, int $toHour): self
|
||||
{
|
||||
$local = ($now ?? CarbonImmutable::now('UTC'))->addMinutes($offsetMinutes);
|
||||
$hour = (int) $local->format('G');
|
||||
if (! self::isValidWindow($fromHour, $toHour)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Границы окна бессмысленны: с {$fromHour} до {$toHour}. "
|
||||
.'Окно «с 20 до 10» означало бы работу всю ночь.'
|
||||
);
|
||||
}
|
||||
|
||||
return $hour >= $this->fromHour() && $hour < $this->toHour();
|
||||
$rule = new self;
|
||||
$rule->from = $fromHour;
|
||||
$rule->to = $toHour;
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Можно ли отдавать сообщение (звонить) прямо сейчас номеру с этим смещением.
|
||||
*
|
||||
* 🔴 Третий довод по умолчанию ПУСТ, и при пустом ответ этого правила ровно тот
|
||||
* же, что был до 07.08.2026: живая рассылка СМС второго окна не задаёт.
|
||||
*
|
||||
* @param int $offsetMinutes смещение ПОЛУЧАТЕЛЯ, минут от UTC
|
||||
* @param LocalHoursWindow|null $secondWindow часы менеджера клиента в ЕГО часах (Р115).
|
||||
* Пусто — рамка получателя и больше ничего.
|
||||
*/
|
||||
public function canSendNow(
|
||||
int $offsetMinutes,
|
||||
?CarbonImmutable $now = null,
|
||||
?LocalHoursWindow $secondWindow = null,
|
||||
): bool {
|
||||
$now = $now ?? CarbonImmutable::now('UTC');
|
||||
|
||||
// Рамка по месту получателя — она же потолок, за неё не выходим никогда.
|
||||
if (! $this->hourIsInside($now, $offsetMinutes, $this->fromHour(), $this->toHour())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($secondWindow === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$narrowed = $this->narrowedWindow($secondWindow);
|
||||
|
||||
// Окно клиента целиком вне рамки (например 20:00–23:00): звонков не будет
|
||||
// вовсе. Молчать об этом нельзя — экран спрашивает {@see narrowedWindow()}.
|
||||
if ($narrowed === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 🔴 Второе окно меряется СВОИМ смещением, а не смещением получателя.
|
||||
// Здесь и есть пересечение в общих часах: оба вопроса заданы про один и
|
||||
// тот же миг `$now`, каждый — в часах своей стороны.
|
||||
return $this->hourIsInside($now, $narrowed->offsetMinutes, $narrowed->fromHour, $narrowed->toHour);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,6 +162,96 @@ final class SmsQuietHours
|
||||
return $open->subMinutes($offsetMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Второе окно, СУЖЕННОЕ рамкой (решение владельца Р64): клиент задаёт часы
|
||||
* своего менеджера, и они рамку только сужают, никогда не расширяют.
|
||||
* 11:00–17:00 остаётся собой, 09:00–22:00 принимается как 10:00–20:00.
|
||||
*
|
||||
* Пусто означает «окна нет вовсе»: клиент задал часы целиком вне рамки —
|
||||
* например 20:00–23:00. Это не «сегодня не выйдет», а «так звонков не будет
|
||||
* никогда», и экран обязан сказать это человеку словами, а не молчать.
|
||||
*
|
||||
* 🔴 Сужение живёт здесь, а не в модуле обзвона: это часть правила окна
|
||||
* (строка Н.3). Второе такое место дало бы клиенту одни часы на экране и
|
||||
* другие в наборе.
|
||||
*/
|
||||
public function narrowedWindow(LocalHoursWindow $secondWindow): ?LocalHoursWindow
|
||||
{
|
||||
$from = max($secondWindow->fromHour, $this->fromHour());
|
||||
$to = min($secondWindow->toHour, $this->toHour());
|
||||
|
||||
if (! self::isValidWindow($from, $to)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LocalHoursWindow($secondWindow->offsetMinutes, $from, $to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ближайший момент (в UTC), когда откроются ОБА окна разом: и рамка по месту
|
||||
* получателя, и суженные часы менеджера в его собственных часах (Р115).
|
||||
*
|
||||
* Пусто означает «общего окна нет вовсе» — и это ответ, а не молчание. Причин
|
||||
* ровно две, и обе надо уметь сказать человеку:
|
||||
* 1. часы клиента целиком вне рамки (20:00–23:00) — {@see narrowedWindow()};
|
||||
* 2. окна не пересекаются из-за поясов: менеджер в Москве 14:00–18:00,
|
||||
* получатель во Владивостоке — у одного всегда закрыто, когда у другого
|
||||
* открыто. Общего окна нет ни сегодня, ни завтра, ни когда-либо.
|
||||
*
|
||||
* 🔴 Почему довольно суток. Оба окна повторяются каждые 24 часа по UTC, и
|
||||
* смещения не двигаются (в России переводов часов нет). Значит если общего
|
||||
* часа нет в ближайшие сутки — его нет никогда, и искать дальше некуда.
|
||||
*
|
||||
* Мгновений проверяем не 1440, а горстку: общее окно может начаться только
|
||||
* тогда, когда открывается одно из двух окон, либо прямо сейчас. Между этими
|
||||
* мигами ничего не меняется.
|
||||
*
|
||||
* Отдельным именем, а не третьим доводом к {@see nextWindowOpensAt()},
|
||||
* нарочно: у вопроса про два окна ответ бывает пустым, а у вопроса про одно —
|
||||
* никогда. Смешав их, мы получили бы пустоту, которую зовущий прочтёт как
|
||||
* «начинаем прямо сейчас».
|
||||
*/
|
||||
public function nextCommonWindowOpensAt(
|
||||
int $offsetMinutes,
|
||||
LocalHoursWindow $secondWindow,
|
||||
?CarbonImmutable $now = null,
|
||||
): ?CarbonImmutable {
|
||||
$now = $now ?? CarbonImmutable::now('UTC');
|
||||
$narrowed = $this->narrowedWindow($secondWindow);
|
||||
|
||||
if ($narrowed === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$limit = $now->addDay();
|
||||
$candidates = [$now];
|
||||
|
||||
foreach ([[$offsetMinutes, $this->fromHour()], [$narrowed->offsetMinutes, $narrowed->fromHour]] as [$offset, $fromHour]) {
|
||||
$midnight = $now->addMinutes($offset)->startOfDay();
|
||||
|
||||
// Сегодняшнее открытие может быть уже позади, завтрашнее — уже за сутками;
|
||||
// лишнее отсеет условие ниже, а пропустить ничего нельзя.
|
||||
foreach ([0, 1] as $day) {
|
||||
$candidates[] = $midnight->addDays($day)->addHours($fromHour)->subMinutes($offset);
|
||||
}
|
||||
}
|
||||
|
||||
$candidates = array_values(array_filter(
|
||||
$candidates,
|
||||
static fn (CarbonImmutable $moment): bool => ! $moment->lessThan($now) && $moment->lessThan($limit),
|
||||
));
|
||||
|
||||
usort($candidates, static fn (CarbonImmutable $a, CarbonImmutable $b): int => $a <=> $b);
|
||||
|
||||
foreach ($candidates as $moment) {
|
||||
if ($this->canSendNow($offsetMinutes, $moment, $secondWindow)) {
|
||||
return $moment;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Когда рассылка реально начнётся (строка листа 3.15): самый ранний момент, в
|
||||
* который у кого-нибудь из получателей откроется окно.
|
||||
@@ -124,8 +303,26 @@ final class SmsQuietHours
|
||||
return (int) $this->to;
|
||||
}
|
||||
|
||||
/**
|
||||
* Попадает ли миг `$nowUtc` в окно `[$fromHour, $toHour)` по часам места со
|
||||
* смещением `$offsetMinutes`.
|
||||
*
|
||||
* 🔴 Единственное место в портале, где час вообще берётся из мига. Оба окна —
|
||||
* и рамка получателя, и часы менеджера — спрашивают отсюда, каждое со своим
|
||||
* смещением. Разъедься эти два счёта, и получилось бы ровно то, от чего
|
||||
* охраняет строка Н.3: по экрану ждём утра, по факту уже звоним.
|
||||
*/
|
||||
private function hourIsInside(CarbonImmutable $nowUtc, int $offsetMinutes, int $fromHour, int $toHour): bool
|
||||
{
|
||||
$hour = (int) $nowUtc->addMinutes($offsetMinutes)->format('G');
|
||||
|
||||
return $hour >= $fromHour && $hour < $toHour;
|
||||
}
|
||||
|
||||
private function load(): void
|
||||
{
|
||||
// Границы уже названы снаружи ({@see withWindow()}) либо прочитаны раньше —
|
||||
// второго запроса не будет ни на каком числе номеров.
|
||||
if ($this->from !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\External;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Fetch\DverRendera;
|
||||
use App\Services\Autopodbor\Agent\Fetch\RenderServiceYandexListSource;
|
||||
use App\Services\Autopodbor\Agent\Fetch\SelfRenderClient;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
@@ -12,6 +15,32 @@ use Illuminate\Support\Facades\Http;
|
||||
* виртуалке-рендере — портал спрашивает её ручку /proxy-check (реальный запрос через прокси →
|
||||
* внешний IP). «Баланс» прокси = срок действия из конфига: за amber_days до конца — amber,
|
||||
* после — red. Не настроено (нет check_url/срока) → grey. Денег не тратит.
|
||||
*
|
||||
* 🔴 Р126, третья дверь. Решение владельца 07.08.2026.
|
||||
*
|
||||
* Эта проба шлёт ТОТ ЖЕ тайный ключ `SELF_RENDER_KEY` заголовком `X-Render-Key`, что и
|
||||
* {@see SelfRenderClient} с
|
||||
* {@see RenderServiceYandexListSource} — но адрес берёт из
|
||||
* ДРУГОЙ настройки, `PROXY_MARKET_CHECK_URL`. До этой правки адрес не проверялся вовсе: опечатка
|
||||
* в одной букве увела бы ключ чужому человеку, а `http://` увёз бы его открытым текстом. Утёк бы
|
||||
* при этом ключ боевого «Поиска клиентов» — то есть цена ошибки тут не «серая плитка», а мёртвый
|
||||
* поиск и чужой человек с нашим ключом.
|
||||
*
|
||||
* 🔴 Список разрешённых машин — ОБЩИЙ с рендером (`services.self_render.allowed_hosts`), свой
|
||||
* не заводится намеренно. Список отвечает на вопрос «куда вообще разрешено везти ЭТОТ ключ», а
|
||||
* ключ один; два списка на один ключ неизбежно разъехались бы, и расширение проксишного списка
|
||||
* молча расширило бы дорогу ключу рендера. Замер: `/render` и `/proxy-check` — две ручки ОДНОГО
|
||||
* процесса на одной машине (`render-service/server.mjs`, обе за одним `RENDER_KEY`), боевой
|
||||
* адрес `https://51-250-1-97.sslip.io/...` (ПИЛОТ.md 06.07.2026).
|
||||
*
|
||||
* 🔴 Р128 (07.08.2026). Дверь проверяет адрес из настройки, но за переадресацией перевозчик
|
||||
* пошёл бы сам: разрешённая машина отвечает «иди туда», и ключ уезжает на непроверенный адрес.
|
||||
* Замер до правки: плитка горела ЗЕЛЁНЫМ и показывала IP ЧУЖОЙ машины как свой. Поэтому за
|
||||
* переадресацией не ходим вовсе; 3xx после этого — это «прокси не отвечает», а не «ок».
|
||||
*
|
||||
* 🔴 Закрытая дверь здесь = СЕРЫЙ, а не красный. Красный на этой плитке значит «прокси
|
||||
* кончились» — владелец побежит их продлевать. Не настроено/не проверяем — это «мы ничего не
|
||||
* знаем», то есть серый. Соврать красным нельзя.
|
||||
*/
|
||||
class ProxyMarketProbe implements LivenessProbe
|
||||
{
|
||||
@@ -29,8 +58,27 @@ class ProxyMarketProbe implements LivenessProbe
|
||||
}
|
||||
|
||||
$key = (string) config('services.self_render.key', '');
|
||||
$dver = new DverRendera($url, $key, self::razreshyonnyeHosty());
|
||||
$zakryta = $dver->pochemuZakryta();
|
||||
|
||||
if ($zakryta !== '') {
|
||||
// Наружу НЕ пошли — ключ никуда не уехал. Серый: мы про прокси ничего не знаем.
|
||||
// Настройку называем вслух, иначе причина «адрес рендер-сервиса …» отправит
|
||||
// владельца править не ту строку в .env.
|
||||
return LivenessReading::unknown(
|
||||
'proxy_market',
|
||||
'не проверяем: '.$zakryta.'; адрес взят из PROXY_MARKET_CHECK_URL',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$resp = Http::withHeaders(['X-Render-Key' => $key])->timeout(12)->get($url);
|
||||
// Ходим ровно по тому адресу, который проверила дверь (без пробелов по краям).
|
||||
// 🔴 Р128: и никуда больше. Ответ «иди на другой адрес» не выполняем — иначе плитка
|
||||
// показала бы владельцу ЧУЖОЙ IP как свой, зелёным, и тайный ключ уехал бы туда же.
|
||||
$resp = Http::withoutRedirecting()
|
||||
->withHeaders(['X-Render-Key' => $key])
|
||||
->timeout(12)
|
||||
->get($dver->adres());
|
||||
if (! $resp->ok() || $resp->json('ok') !== true) {
|
||||
$reason = (string) ($resp->json('error') ?? ('HTTP '.$resp->status()));
|
||||
|
||||
@@ -78,6 +126,20 @@ class ProxyMarketProbe implements LivenessProbe
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Разрешённые машины ключа рендера, приведённые к строкам. Список ОБЩИЙ с обеими уже
|
||||
* закрытыми дверями — см. шапку класса и `AutopodborServiceProvider::razreshyonnyeHosty()`.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function razreshyonnyeHosty(): array
|
||||
{
|
||||
/** @var array<array-key, mixed> $hosty */
|
||||
$hosty = (array) config('services.self_render.allowed_hosts', []);
|
||||
|
||||
return array_values(array_map(strval(...), $hosty));
|
||||
}
|
||||
|
||||
private function ipSuffix(string $ip): string
|
||||
{
|
||||
return $ip !== '' ? ', IP '.$ip : '';
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Obzvon;
|
||||
|
||||
use App\Services\External\SelfRenderLivenessProbe;
|
||||
|
||||
/**
|
||||
* Чем кончилось ОДНО обращение портала к голосовому роботу — задача З-0.3.
|
||||
*
|
||||
* 🔴🔴 ПОЧЕМУ ИСХОДОВ ПЯТЬ, А НЕ ДВА («вышло / не вышло»).
|
||||
*
|
||||
* Соблазн написать `bool $ok` очень силён и стоит он живому человеку двойного
|
||||
* звонка. Разбор такой. Портал не получил ответа — это НЕ значит, что звонка не
|
||||
* было. Робот мог принять задание, уже набрать номер и заговорить с человеком, а
|
||||
* ответ до портала не доехал: оборвался обратный путь. Ответь клиент в этом
|
||||
* случае «не вышло» — работник очереди честно повторит задание, и **живому
|
||||
* человеку позвонят второй раз**, а с кошелька клиента спишут вторую трубку.
|
||||
* Отыграть нельзя: набранный номер уже набран.
|
||||
*
|
||||
* Поэтому «не вышло» разрезано на две разные правды:
|
||||
* • {@see self::Neizvestno} — МЫ НЕ ЗНАЕМ. Звонок мог состояться;
|
||||
* • {@see self::Otkaz}, {@see self::Zanyato}, {@see self::DverZakryta} —
|
||||
* звонка ТОЧНО не было, и это доказуемо: либо робот сам сказал «нет», либо
|
||||
* мы вообще не выходили наружу.
|
||||
*
|
||||
* Разница между этими двумя правдами — это разница между «повторить безопасно»
|
||||
* и «повторять можно только тем же опознавательным знаком задания».
|
||||
* См. {@see ObzvonOtvet::zvonokMogSostoyatsya()}.
|
||||
*/
|
||||
enum IskhodObrashcheniya: string
|
||||
{
|
||||
/**
|
||||
* Робот ответил УСЛОВЛЕННЫМ СЛОВОМ: `{"ok": true, ...}`.
|
||||
*
|
||||
* 🔴 Только это считается успехом. «Ответил 200» успехом НЕ считается —
|
||||
* ровно на этом сгорел сторож соседнего шва self_render: правило «ответ
|
||||
* меньше 500 = жив» физически не могло покраснеть и стоило дня поисков
|
||||
* причины не там (см. {@see SelfRenderLivenessProbe}).
|
||||
*/
|
||||
case Poluchen = 'poluchen';
|
||||
|
||||
/**
|
||||
* Робот САМ сказал «нет»: `{"ok": false, "reason": "..."}` либо ответил 4xx —
|
||||
* то есть понял просьбу и отказался её исполнять.
|
||||
*
|
||||
* Звонка не было — это слово робота, а не наша догадка. Повторять безопасно
|
||||
* (хотя обычно бессмысленно, пока не устранена причина отказа).
|
||||
*/
|
||||
case Otkaz = 'otkaz';
|
||||
|
||||
/**
|
||||
* Свободного места у робота не нашлось за отведённое время ожидания.
|
||||
*
|
||||
* 🔴 Наружу МЫ НЕ ХОДИЛИ ВОВСЕ — ни одного байта не отправлено. Звонка точно
|
||||
* не было. Это не отказ и не молчание: это «сейчас занято, зайдите позже».
|
||||
* Отдельный исход нужен, чтобы работник очереди не записал занятость в
|
||||
* «робот молчит» и не поднял ложную тревогу.
|
||||
*/
|
||||
case Zanyato = 'zanyato';
|
||||
|
||||
/**
|
||||
* Шов не настроен или закрыт нами самими: пуст адрес, пуст ключ, задание без
|
||||
* опознавательного знака, либо адрес ведёт на ХОСТ, которого нет в списке
|
||||
* разрешённых.
|
||||
*
|
||||
* Наружу не ходили, ключ никуда не отправляли, звонка точно не было.
|
||||
*/
|
||||
case DverZakryta = 'dver_zakryta';
|
||||
|
||||
/**
|
||||
* 🔴🔴 МЫ НЕ ЗНАЕМ, что произошло. Робот промолчал, оборвал связь, вышел за
|
||||
* предел времени, ответил 5xx или ответил 200 и МУСОРОМ.
|
||||
*
|
||||
* 🪤 Мусор в ответе — это именно «не знаю», а НЕ «отказ». Тело мог покалечить
|
||||
* посредник (прокси, балансировщик) уже после того, как робот принял задание
|
||||
* и начал звонить. Записать мусор в «отказ» значит вернуть ровно ту дыру, от
|
||||
* которой пятёрка исходов и заведена.
|
||||
*/
|
||||
case Neizvestno = 'neizvestno';
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Obzvon;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Fetch\SelfRenderClient;
|
||||
use Illuminate\Contracts\Cache\Lock;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Портальная половина шва «портал умеет позвать голосового робота» — задача З-0.3.
|
||||
*
|
||||
* Робот живёт на ОТДЕЛЬНОЙ машине. Портал умеет ровно три вещи: попросить
|
||||
* позвонить ({@see self::pozvonit()}), спросить, что с разговором
|
||||
* ({@see self::sostoyanie()}), и велеть остановить ({@see self::ostanovit()}).
|
||||
* Роботной половины (приёмник заданий, З-0.6) ещё НЕТ — эта половина от неё не
|
||||
* зависит и проверяется подставным роботом.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 🔴🔴 ЧЕМУ НАУЧИЛ СОСЕДНИЙ ШОВ И ПОЧЕМУ ЭТОТ КЛИЕНТ НЕ КОПИЯ SelfRenderClient
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Образец шва — `services.self_render` + {@see SelfRenderClient}.
|
||||
* Форму настроек (адрес, ключ, предел, всё через env) взяли оттуда. А вот два
|
||||
* его решения перенести сюда НЕЛЬЗЯ, и это не придирка:
|
||||
*
|
||||
* 1. **SelfRenderClient САМ повторяет запрос до двух раз.** Там это правильно:
|
||||
* повторно отрисованная страница ничего не стоит. Здесь повтор — это
|
||||
* ВТОРОЙ ЗВОНОК ЖИВОМУ ЧЕЛОВЕКУ и вторая трубка с кошелька клиента.
|
||||
* Поэтому этот клиент **не повторяет ничего и никогда**: одно обращение —
|
||||
* один запрос. Решение о повторе принимает тот, кто знает про попытки, и
|
||||
* принимает его глядя на {@see ObzvonOtvet::zvonokMogSostoyatsya()}.
|
||||
*
|
||||
* 2. **SelfRenderClient на любую беду возвращает пустую строку.** «Пусто» там
|
||||
* значит «страницы нет». Здесь «пусто» слилось бы в «звонка не было» — а это
|
||||
* неправда: робот мог принять задание и уже звонить, пока ответ терялся по
|
||||
* дороге. Поэтому ответ — не bool и не строка, а {@see ObzvonOtvet} с пятью
|
||||
* разными исходами, где «не знаю» отделено от «точно не было».
|
||||
*
|
||||
* А вот главный урок оттуда взят дословно: **успех — только по условленному
|
||||
* слову**. У self_render сторож «ответ меньше 500 = жив» физически не мог
|
||||
* покраснеть и стоил дня поисков причины не там. Здесь принято ровно одно:
|
||||
* `{"ok": true}` от робота. Ответ 200 с мусором — это {@see IskhodObrashcheniya::Neizvestno},
|
||||
* а не успех и не отказ.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 🔴🔴 ОПОЗНАВАТЕЛЬНЫЙ ЗНАК ЗАДАНИЯ — ЗАЧЕМ ОН И ПОЧЕМУ БЕЗ НЕГО НЕ ЗВОНИМ
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Случай, ради которого всё это заведено: робот ПРИНЯЛ задание и уже набирает
|
||||
* номер, а ответ до портала не дошёл — оборвался обратный путь. Портал видит
|
||||
* молчание. Повторит вслепую — человеку позвонят дважды.
|
||||
*
|
||||
* Никакая обработка ошибок на стороне портала этого не лечит: портал физически
|
||||
* не может отличить «не дошло туда» от «не дошло обратно». Отличить может
|
||||
* только робот — и только если у задания есть знак, по которому робот узнаёт,
|
||||
* что это то же самое задание, а не новое. Знак даёт {@see self::klyuchZadaniya()}:
|
||||
* он ОДИНАКОВ у всех повторов одной и той же попытки и РАЗНЫЙ у разных попыток.
|
||||
*
|
||||
* 🔴 Знак не случайный и не выдуманный: он посчитан от арендатора, кампании,
|
||||
* номера и числа попытки. Случайный выглядел бы как защита и не защищал бы
|
||||
* ничего — при каждом повторе он был бы новым.
|
||||
*
|
||||
* 🔴 И знак не приходит СНАРУЖИ. {@see self::pozvonit()} принимает то, ИЗ ЧЕГО
|
||||
* знак складывается, и считает его сам, а номер и для знака, и для тела берёт из
|
||||
* одного места ({@see self::odinVidNomera()}). Пока знак и телефон были двумя
|
||||
* независимыми доводами, ничто не заставляло их сойтись: рассогласованная пара
|
||||
* доезжала до робота, робот отвечал отказом, и человеку не звонили МОЛЧА.
|
||||
* Теперь рассогласовать нечего — второго номера в этом клиенте не существует.
|
||||
*
|
||||
* 🪤 Чего эта половина НЕ доказывает: что робот знак и вправду читает. Приёмник
|
||||
* (З-0.6) обязан хранить знаки и на повтор отвечать `{"ok":true, "call_id": <тот
|
||||
* же>, "povtor": true}` вместо второго набора. Пока приёмника нет — защита
|
||||
* ЗАЯВЛЕНА, но не замкнута. Так и записано в отчёте, «сделано» не называется.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 🔴 Р123 — К РОБОТУ ТОЛЬКО ПО ШИФРОВАННОЙ ДОРОГЕ, КРОМЕ СВОЕЙ МАШИНЫ
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Тайный ключ едет ЗАГОЛОВКОМ в каждом запросе. Приёмка 07.08.2026 замерила:
|
||||
* разрешённый хост + `http://` уходил наружу молча — ключ ехал открытым текстом,
|
||||
* и признака этому не было никакого. Одной опечатки в `OBZVON_ENDPOINT` хватало.
|
||||
*
|
||||
* Решение владельца: адрес без шифрования портал просто НЕ ПРИНИМАЕТ — говорит
|
||||
* «дверь закрыта» и звонить не будет. Исключение ровно одно — разработчик на
|
||||
* своём компьютере, и оно устроено ДВУМЯ независимыми ответами
|
||||
* ({@see self::svoyaMashina()}), чтобы одной перепутанной настройки на боевом
|
||||
* для утечки не хватило.
|
||||
*
|
||||
* 🪤 Чего Р123 НЕ закрывает, говорю прямо: шифрование бережёт ключ В ПУТИ, но
|
||||
* НЕ доказывает, что на том конце наш робот, а не чужая машина, занявшая тот же
|
||||
* адрес. Это отдельная задача (закрепить отпечаток или свой корень доверия), и
|
||||
* она не здесь.
|
||||
*
|
||||
* 🪤 И второе: если робот когда-нибудь уедет за посредника, снимающего
|
||||
* шифрование на входе (`http://robot.vnutri:8080`), эта дверь его закроет — и
|
||||
* закроет правильно. Лечится это шифрованием на внутреннем участке либо новым
|
||||
* решением владельца, но НЕ настройкой-обходом: настройка-обход и есть та самая
|
||||
* опечатка, ради которой всё это заведено.
|
||||
*
|
||||
* 🔴 Р128 (07.08.2026) — И НЕ ХОДИМ ЗА ПЕРЕАДРЕСАЦИЕЙ. Дверь Р123 проверяет адрес ИЗ
|
||||
* НАСТРОЙКИ. Второй адрес прилетает ОТВЕТОМ, и дверь про него не знает: разрешённая машина
|
||||
* отвечает «иди туда» — и тайный ключ едет на непроверенную машину, а с ним и задание.
|
||||
* Замерено на подставных машинах 07.08.2026. Запрет стоит в {@see self::sprosit()}. Ответ
|
||||
* 3xx после запрета попадает в «не знаю» — и это правильно: робот мог и не увидеть задания,
|
||||
* а повтор с ТЕМ ЖЕ знаком второго звонка живому человеку не даст.
|
||||
*
|
||||
* 🪤 Не замерено, говорю прямо: живого робота на машине ещё нет, так что «настоящая
|
||||
* виртуалка обзвона не переадресует» — пока предположение, а не измерение.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 🔴 ПРЕДЕЛ ОДНОВРЕМЕННЫХ — ЧТО ОН НА САМОМ ДЕЛЕ ОГРАНИЧИВАЕТ
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Решение владельца Р84: робот делит железо с боевым рендером «Поиска клиентов»,
|
||||
* и голос не имеет права его уронить. Предел взят НЕ числом в памяти процесса
|
||||
* (такой предел при двух работниках очереди даёт двойную нагрузку и ничего не
|
||||
* охраняет), а `concurrency` именованными замками общего кэша: «мест» ровно
|
||||
* столько, сколько сказано в настройке, и работники в разных процессах делят
|
||||
* одни и те же места.
|
||||
*
|
||||
* Что он ограничивает честно: число ОДНОВРЕМЕННЫХ ОБРАЩЕНИЙ портала к машине
|
||||
* робота. Ровно это и грузит чужое железо.
|
||||
*
|
||||
* 🪤 Чего он НЕ ограничивает — говорю прямо: число ОДНОВРЕМЕННО ИДУЩИХ
|
||||
* РАЗГОВОРОВ. Место освобождается, как только робот ответил «принял», а сам
|
||||
* разговор после этого живёт минуты. Считать живые разговоры портал не может
|
||||
* вовсе — он не знает, когда они кончаются, пока робот не отчитается. Это
|
||||
* предел робота (З-0.6) либо счёт по таблице `obzvon_calls`, и он не здесь.
|
||||
*
|
||||
* 🪤 И ещё: замки живут в кэше. При `CACHE_STORE=file` (боевой) и `redis` это
|
||||
* общий счёт на машину/кластер, при `array` (тесты) — на процесс. Скажи кто-то
|
||||
* «предел общий на весь мир» — соврал бы.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 🔴 ЖУРНАЛ — ТОЛЬКО ОТ «ПРЕДУПРЕЖДЕНИЯ» И ВЫШЕ
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* На боевом сервере `LOG_LEVEL=warning`: всё ниже предупреждения не доезжает в
|
||||
* журнал НИКОГДА. Оплачено 04.08.2026 — починка выглядела сделанной и была
|
||||
* мёртвой, потому что говорила `Log::notice`. Здесь любая беда шва пишется
|
||||
* `warning` или `error`, ни одного `info`/`notice` в файле нет намеренно.
|
||||
*
|
||||
* 🪤 В журнал не попадает ни телефон, ни тайный ключ: телефон — персональные
|
||||
* данные, ключ — секрет. В строке живёт опознавательный знак задания (он от
|
||||
* телефона необратим) и адрес ручки.
|
||||
*/
|
||||
final class ObzvonClient
|
||||
{
|
||||
/** Приставка имени замка-места. Мест ровно `concurrency` штук: …:0 … :N-1. */
|
||||
private const PREFIKS_MESTA = 'obzvon:mesto:';
|
||||
|
||||
/**
|
||||
* @param list<string> $razreshyonnyeHosty хосты, куда клиенту разрешено ходить с ключом
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $endpoint,
|
||||
private readonly string $key,
|
||||
private readonly int $concurrency = 4,
|
||||
private readonly int $waitSeconds = 10,
|
||||
private readonly int $connectTimeout = 5,
|
||||
private readonly int $timeout = 15,
|
||||
private readonly array $razreshyonnyeHosty = [],
|
||||
) {}
|
||||
|
||||
/** Собрать клиента по настройкам `services.obzvon`. */
|
||||
public static function izNastroek(): self
|
||||
{
|
||||
/** @var array<array-key, mixed> $hosty */
|
||||
$hosty = (array) config('services.obzvon.allowed_hosts', []);
|
||||
|
||||
return new self(
|
||||
endpoint: trim((string) config('services.obzvon.endpoint', '')),
|
||||
key: trim((string) config('services.obzvon.key', '')),
|
||||
concurrency: (int) config('services.obzvon.concurrency', 4),
|
||||
waitSeconds: (int) config('services.obzvon.wait_seconds', 10),
|
||||
connectTimeout: (int) config('services.obzvon.connect_timeout', 5),
|
||||
timeout: (int) config('services.obzvon.timeout', 15),
|
||||
razreshyonnyeHosty: array_values(array_map(strval(...), $hosty)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 ОПОЗНАВАТЕЛЬНЫЙ ЗНАК ОДНОЙ ПОПЫТКИ дозвона. Одинаков у всех повторов
|
||||
* этой же попытки — на том и стоит защита от двойного звонка. Разный у
|
||||
* разных попыток: вторая попытка дозвона — это законный второй звонок, и
|
||||
* глушить его нельзя.
|
||||
*
|
||||
* 🪤 Телефон входит в знак только хешем: знак ложится в журналы и уедет к
|
||||
* роботу, а телефон — персональные данные человека.
|
||||
*
|
||||
* 🔴 Номер приводится к одному виду {@see self::odinVidNomera()} — тем же
|
||||
* самым, каким он поедет роботу в теле задания. Это одно место, а не два.
|
||||
*/
|
||||
public static function klyuchZadaniya(int $tenantId, int $campaignId, string $telefon, int $popytkaNo): string
|
||||
{
|
||||
return 'z'.substr(hash('sha256', implode('|', [
|
||||
$tenantId,
|
||||
$campaignId,
|
||||
self::odinVidNomera($telefon),
|
||||
$popytkaNo,
|
||||
])), 0, 40);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 ОДИН ВИД НОМЕРА — единственное место во всём клиенте, где номер
|
||||
* приводится к канону, и оба потребителя берут его отсюда: и знак
|
||||
* ({@see self::klyuchZadaniya()}), и тело задания ({@see self::pozvonit()}).
|
||||
*
|
||||
* Пока мест было два, один и тот же человек, записанный `+7 999 000-00-01`
|
||||
* и `79990000001`, давал ОДИН знак и РАЗНЫЕ тела. Приёмник робота (З-0.6)
|
||||
* снимает отпечаток с тела ДОСЛОВНО и на такую пару отвечает 422 — то есть
|
||||
* честный повтор получал отказ вместо звонка.
|
||||
*
|
||||
* 🔴 Р127 (решение владельца 07.08.2026). Канон снимает не только ЗАПИСЬ
|
||||
* (плюсы, скобки, пробелы, тире), но и разницу между `8999…` и `+7999…` —
|
||||
* ровно при ОДИННАДЦАТИ цифрах. Это один и тот же живой человек, записанный
|
||||
* двумя обычными способами; до Р127 он получал два разных знака, ему звонили
|
||||
* дважды, и клиент платил за два звонка вместо одного.
|
||||
*
|
||||
* 🔴 Почему правило такое узкое, а не «любой номер с восьмёрки». Ошибка в
|
||||
* обратную сторону тише и хуже: слей мы двух РАЗНЫХ людей в один знак, робот
|
||||
* счёл бы второе задание повтором первого, и второму человеку не позвонили бы
|
||||
* НИКОГДА и МОЛЧА. Ровно одиннадцать цифр с ведущей `8` — это российский
|
||||
* междугородний набор (та же форма и у казахстанских `+7`), и второго
|
||||
* толкования у него в наших списках нет. Короткие (`8495123`) и длинные
|
||||
* (`8999000000012`) не трогаем ВОВСЕ: там `8` может значить что угодно.
|
||||
*
|
||||
* 🪤 Цена, названная владельцу вслух: одиннадцатизначные ИНОСТРАННЫЕ номера,
|
||||
* начинающиеся на `8` (Вьетнам `+84`, Гонконг `+852`, Япония `+81`), это
|
||||
* правило испортит — `84…` превратится в `74…`. Вьетнамский при этом может
|
||||
* стать ПОХОЖИМ на живой российский, и позвонят чужому человеку. Лечится это
|
||||
* чисткой номеров на входе, при загрузке списка, — отдельная задача, которой
|
||||
* в плане сейчас нет.
|
||||
*
|
||||
* 🪤 И второе, тоже вслух: `9990000001` (десять цифр, без кода страны) для
|
||||
* этого канона по-прежнему ДРУГОЙ человек, чем `79990000001`. А проверка
|
||||
* запрета обзвона ({@see ObzvonZapretObrabotki}) сверяет ПОСЛЕДНИЕ ДЕСЯТЬ
|
||||
* цифр и считает их одним. Расхождение узкое и намеренное — Р127 короткие
|
||||
* номера трогать запретил.
|
||||
*
|
||||
* 🔴 Правило устойчиво к повтору: приведённый номер, поданный сюда второй
|
||||
* раз, не меняется. На это опирается {@see self::pozvonit()} — он приводит
|
||||
* номер сам, а {@see self::klyuchZadaniya()} приводит его ещё раз.
|
||||
*/
|
||||
private static function odinVidNomera(string $telefon): string
|
||||
{
|
||||
$cifry = preg_replace('/\D+/', '', $telefon) ?? '';
|
||||
|
||||
if (strlen($cifry) === 11 && str_starts_with($cifry, '8')) {
|
||||
return '7'.substr($cifry, 1);
|
||||
}
|
||||
|
||||
return $cifry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Попросить робота позвонить по номеру.
|
||||
*
|
||||
* 🔴 Один вызов = РОВНО ОДИН запрос к роботу. Сам не повторяет — см. шапку.
|
||||
*
|
||||
* 🔴 ЗНАК СЧИТАЕТСЯ ЗДЕСЬ, А НЕ ПРИНИМАЕТСЯ СНАРУЖИ. Раньше знак и телефон
|
||||
* приходили двумя независимыми доводами, и ничто не заставляло их сойтись:
|
||||
* работник очереди, посчитавший знак один раз и перебирающий номера, отправил
|
||||
* бы роботу рассогласованную пару — робот ответил бы 422, а человеку не
|
||||
* позвонили бы МОЛЧА. Теперь рассогласовать нечего: наружу принимается ровно
|
||||
* то, ИЗ ЧЕГО знак складывается, и складывает его сам клиент.
|
||||
*
|
||||
* 🪤 Что отсюда следует для правила счёта попыток (решение владельца Р124).
|
||||
* Робот помнит знаки сутки и различает попытки по `$popytkaNo`. Значит на
|
||||
* ПОВТОРЕ после исхода {@see ObzvonOtvet::zvonokMogSostoyatsya()} = «да»
|
||||
* (робот молчит, 5xx, ответ без условленного слова) номер попытки обязан
|
||||
* остаться ТЕМ ЖЕ — иначе знак станет другим и защита не сработает именно
|
||||
* в том случае, ради которого заведена. Прибавлять попытку можно только
|
||||
* после исхода, где звонка ТОЧНО не было (дверь закрыта, занято, отказ),
|
||||
* либо после состоявшегося и завершённого разговора. Сам клиент этого
|
||||
* правила не знает и знать не должен — он считает знак от того, что ему
|
||||
* сказали. Довод виден в каждом вызове нарочно: правило будет исполняться
|
||||
* на глазах, а не прятаться внутри.
|
||||
*
|
||||
* @param array<string, mixed> $dopolnitelno что ещё сказать роботу (сценарий, номер «от кого»)
|
||||
*
|
||||
* 🪤 `$dopolnitelno` тоже входит в отпечаток тела у робота. Значит оно
|
||||
* обязано быть ОДИНАКОВЫМ у повтора той же попытки: время «сейчас», случайное
|
||||
* число или поле, добавленное выкатом между попыткой и повтором, дадут
|
||||
* честному повтору 422. Порядок ключей роли не играет — приёмник сортирует.
|
||||
*/
|
||||
public function pozvonit(int $tenantId, int $campaignId, string $telefon, int $popytkaNo, array $dopolnitelno = []): ObzvonOtvet
|
||||
{
|
||||
// 🔴 Один и тот же канон и для знака, и для тела: роботу уезжает ровно
|
||||
// тот номер, из которого посчитан знак. Другого номера здесь просто нет.
|
||||
$nomer = self::odinVidNomera($telefon);
|
||||
|
||||
if ($nomer === '') {
|
||||
// Сюда попадает и пустая строка, и строка вовсе без цифр («абв»).
|
||||
// Второе важно отдельно: раньше такие строки давали ОДИН знак на
|
||||
// всех (цифр ноль — хешу нечего различать), и два разных задания
|
||||
// столкнулись бы знаками у робота.
|
||||
Log::error('обзвон: задание без номера — звонить отказано', [
|
||||
'ruchka' => 'pozvonit',
|
||||
]);
|
||||
|
||||
return ObzvonOtvet::dverZakryta('задание без номера');
|
||||
}
|
||||
|
||||
$znak = self::klyuchZadaniya($tenantId, $campaignId, $nomer, $popytkaNo);
|
||||
|
||||
return $this->obratitsya('pozvonit', $znak, array_merge($dopolnitelno, [
|
||||
'zadanie' => $znak,
|
||||
'telefon' => $nomer,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Спросить робота, что с разговором. Внешний номер звонка выдаёт сам робот
|
||||
* (он же ложится в `obzvon_calls.external_call_id`).
|
||||
*
|
||||
* 🪤 Исход {@see IskhodObrashcheniya::Neizvestno} здесь значит «не знаем, что
|
||||
* с разговором», а не «разговора нет». Разница та же, что и у `pozvonit`.
|
||||
*/
|
||||
public function sostoyanie(string $vneshniyIdZvonka): ObzvonOtvet
|
||||
{
|
||||
$vneshniyIdZvonka = trim($vneshniyIdZvonka);
|
||||
|
||||
if ($vneshniyIdZvonka === '') {
|
||||
Log::error('обзвон: спрошено состояние без номера звонка');
|
||||
|
||||
return ObzvonOtvet::dverZakryta('не назван номер звонка');
|
||||
}
|
||||
|
||||
return $this->obratitsya('sostoyanie', $vneshniyIdZvonka, ['call_id' => $vneshniyIdZvonka]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Велеть роботу остановить разговор.
|
||||
*
|
||||
* 🪤 «Остановить» — просьба, а не факт. Ответ {@see IskhodObrashcheniya::Neizvestno}
|
||||
* означает, что разговор МОЖЕТ идти дальше; тишина ответа не равна тишине в
|
||||
* трубке. Убедиться в остановке можно только через {@see self::sostoyanie()}.
|
||||
*/
|
||||
public function ostanovit(string $vneshniyIdZvonka, string $prichina = ''): ObzvonOtvet
|
||||
{
|
||||
$vneshniyIdZvonka = trim($vneshniyIdZvonka);
|
||||
|
||||
if ($vneshniyIdZvonka === '') {
|
||||
Log::error('обзвон: велено остановить без номера звонка');
|
||||
|
||||
return ObzvonOtvet::dverZakryta('не назван номер звонка');
|
||||
}
|
||||
|
||||
return $this->obratitsya('ostanovit', $vneshniyIdZvonka, [
|
||||
'call_id' => $vneshniyIdZvonka,
|
||||
'prichina' => $prichina,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Открыта ли дверь: есть адрес, есть ключ, адрес ведёт на РАЗРЕШЁННЫЙ хост
|
||||
* и по ШИФРОВАННОЙ дороге (решение владельца Р123).
|
||||
*
|
||||
* Пустая строка = открыта. Иначе — человеческая причина, почему закрыта.
|
||||
*/
|
||||
public function pochemuDverZakryta(): string
|
||||
{
|
||||
if ($this->endpoint === '') {
|
||||
return 'не задан адрес робота';
|
||||
}
|
||||
|
||||
if ($this->key === '') {
|
||||
return 'не задан тайный ключ робота';
|
||||
}
|
||||
|
||||
$host = (string) (parse_url($this->endpoint, PHP_URL_HOST) ?: '');
|
||||
|
||||
if ($host === '') {
|
||||
return 'адрес робота не разобрать — в нём нет хоста';
|
||||
}
|
||||
|
||||
// 🔴 Ключ уходит В ЗАГОЛОВКЕ каждого запроса. Опечатка в адресе увела бы
|
||||
// его чужому человеку молча и навсегда. Пустой список = закрыто всё:
|
||||
// «раз не сказано куда можно — значит никуда», а не наоборот.
|
||||
if (! in_array(mb_strtolower($host), array_map('mb_strtolower', $this->razreshyonnyeHosty), true)) {
|
||||
return 'адрес робота ведёт на неразрешённый хост '.$host;
|
||||
}
|
||||
|
||||
$shema = mb_strtolower((string) (parse_url($this->endpoint, PHP_URL_SCHEME) ?: ''));
|
||||
|
||||
// 🔴 Р123. Разрешённый хост НЕ означает безопасную дорогу: `http://` к тому
|
||||
// же самому роботу везёт тайный ключ открытым текстом. Замерено приёмкой
|
||||
// 07.08.2026 — уходило молча, без единого признака.
|
||||
if ($shema !== 'https' && ! $this->svoyaMashina($host)) {
|
||||
return 'адрес робота без шифрования ('
|
||||
.($shema === '' ? 'схема не указана' : $shema.'://')
|
||||
.') — тайный ключ уехал бы открытым текстом';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 Единственное исключение из Р123: разработчик на СВОЁМ компьютере.
|
||||
*
|
||||
* «Своя машина» определяется не списком разрешённых слов — такой список сам
|
||||
* становится дверью («сегодня localhost и 127.0.0.1, завтра четвёртый»), — а
|
||||
* ДВУМЯ независимыми ответами, и оба обязаны совпасть:
|
||||
*
|
||||
* 1. **Где живёт само приложение.** Спрашиваем не себя, а Laravel: он знает
|
||||
* это из `APP_ENV`. Список положительный, а не «всё кроме боевого»: любое
|
||||
* окружение, которого тут не назвали (боевое, витрина, что заведут завтра),
|
||||
* шифрование обязано. Это та же осторожность, что и у пустого списка
|
||||
* хостов выше — «не сказано, что можно, значит нельзя».
|
||||
* 2. **Где стоит робот.** Адрес обязан вести на петлю ЭТОЙ ЖЕ машины: тогда
|
||||
* запрос физически не покидает компьютер и подслушивать его негде.
|
||||
*
|
||||
* 🔴 Зачем именно два, а не один. Владелец назвал незакрытым: «ошибись
|
||||
* кто-нибудь настройкой на боевом так, чтобы портал счёл себя своей машиной,
|
||||
* — запрет отвалится». С двумя ответами одной такой ошибки НЕ хватает:
|
||||
* боевой робот живёт на чужом железе, петлёй его адрес не станет, и ключ
|
||||
* наружу всё равно не уйдёт. Чтобы утечка состоялась, сломать надо оба.
|
||||
*/
|
||||
private function svoyaMashina(string $host): bool
|
||||
{
|
||||
if (! app()->environment('local', 'testing')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->petlya($host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ведёт ли адрес на петлю этой самой машины — по существу, а не по написанию.
|
||||
*
|
||||
* 🪤 Резолвер имён сюда намеренно не зовём. Он превратил бы проверку
|
||||
* безопасности в неопределённую (чужой медленный DNS вешает обзвон) и открыл
|
||||
* бы щель между «проверили» и «пошли»: имя, ответившее петлёй на проверке,
|
||||
* к моменту запроса может отвечать чужим адресом. Разработчику, у которого
|
||||
* робот назван своим именем в hosts, дверь скажет причину вслух, а лечится
|
||||
* это одной правкой адреса на `127.0.0.1`.
|
||||
*/
|
||||
private function petlya(string $host): bool
|
||||
{
|
||||
// Квадратные скобки — как IPv6 пишется в адресе: http://[::1]:8080/…
|
||||
$host = trim(mb_strtolower(trim($host)), '[]');
|
||||
|
||||
// RFC 6761 §6.3: `localhost` и всё под `.localhost` закреплены за петлёй
|
||||
// этой машины СТАНДАРТОМ. Это не наше усмотрение и не список, который
|
||||
// завтра дополнят строкой. 🪤 `localhost.chuzhoy.ru` сюда не попадает:
|
||||
// сверяется конец имени, а не наличие слова внутри.
|
||||
if ($host === 'localhost' || str_ends_with($host, '.localhost')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dvoichnyy = inet_pton($host);
|
||||
|
||||
if (! is_string($dvoichnyy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ::ffff:127.0.0.1 — та же петля, записанная по-шестому.
|
||||
if (strlen($dvoichnyy) === 16 && str_starts_with($dvoichnyy, str_repeat("\0", 10)."\xff\xff")) {
|
||||
$dvoichnyy = substr($dvoichnyy, 12);
|
||||
}
|
||||
|
||||
// Петля — это ВСЯ сеть 127.0.0.0/8, а не один 127.0.0.1: у разработчика
|
||||
// сервер может стоять на 127.0.1.1 (Debian) или 127.0.0.53 (resolved).
|
||||
if (strlen($dvoichnyy) === 4) {
|
||||
return $dvoichnyy[0] === "\x7f";
|
||||
}
|
||||
|
||||
return $dvoichnyy === str_repeat("\0", 15)."\x01";
|
||||
}
|
||||
|
||||
/**
|
||||
* Одно обращение к роботу: занять место → сходить → отдать место.
|
||||
*
|
||||
* @param array<string, mixed> $telo
|
||||
*/
|
||||
private function obratitsya(string $ruchka, string $metka, array $telo): ObzvonOtvet
|
||||
{
|
||||
$zakryta = $this->pochemuDverZakryta();
|
||||
|
||||
if ($zakryta !== '') {
|
||||
// Мягкая деградация: обзвон встаёт и говорит об этом. Не сыплет
|
||||
// исключениями и не притворяется успехом. Уровень — предупреждение:
|
||||
// на бою всё ниже него в журнал не доезжает.
|
||||
Log::warning('обзвон: дверь к роботу закрыта — '.$zakryta, [
|
||||
'ruchka' => $ruchka,
|
||||
'metka' => $metka,
|
||||
]);
|
||||
|
||||
return ObzvonOtvet::dverZakryta($zakryta);
|
||||
}
|
||||
|
||||
$mesto = $this->zanyatMesto();
|
||||
|
||||
if (! $mesto instanceof Lock) {
|
||||
Log::warning('обзвон: все места у робота заняты, наружу не ходили', [
|
||||
'ruchka' => $ruchka,
|
||||
'metka' => $metka,
|
||||
'mest' => max(1, $this->concurrency),
|
||||
'zhdali_sek' => max(0, $this->waitSeconds),
|
||||
]);
|
||||
|
||||
return ObzvonOtvet::zanyato('все '.max(1, $this->concurrency).' мест у робота заняты');
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->sprosit($ruchka, $metka, $telo);
|
||||
} finally {
|
||||
// Место отдаём В ЛЮБОМ случае, включая исключение изнутри: иначе одна
|
||||
// беда навсегда съедала бы место и обзвон затухал бы по одному.
|
||||
$mesto->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сам поход к роботу. Единственное место, где вообще есть сеть.
|
||||
*
|
||||
* @param array<string, mixed> $telo
|
||||
*/
|
||||
private function sprosit(string $ruchka, string $metka, array $telo): ObzvonOtvet
|
||||
{
|
||||
$url = rtrim($this->endpoint, '/').'/'.$ruchka;
|
||||
|
||||
try {
|
||||
$otvet = Http::connectTimeout(max(1, $this->connectTimeout))
|
||||
// 🔴 Это предел на ВЕСЬ обмен, а не только на соединение. Он и
|
||||
// ловит «робот отдаёт ответ по байту бесконечно»: connectTimeout
|
||||
// такой случай пропустил бы — соединение-то установилось.
|
||||
->timeout(max(1, $this->timeout))
|
||||
// 🔴 Р128: за переадресацией НЕ ходим. Дверь проверила адрес из настройки —
|
||||
// и только его; второй адрес прилетает ответом, и о нём она не знает ничего.
|
||||
// Перевозчик при уходе на чужой адрес снимает лишь `Authorization` и `Cookie`
|
||||
// (Guzzle `RedirectMiddleware.php`, строки 199–202), а `X-Obzvon-Key` не из
|
||||
// них — тайный ключ робота уехал бы чужому вместе с заданием.
|
||||
->withoutRedirecting()
|
||||
->withHeaders([
|
||||
'X-Obzvon-Key' => $this->key,
|
||||
// Знак задания едет и своим заголовком, и общепринятым:
|
||||
// приёмнику (З-0.6) хватит любого, а посредник, срезающий
|
||||
// нестандартные заголовки, не обезоружит защиту молча.
|
||||
'X-Obzvon-Zadanie' => $metka,
|
||||
'Idempotency-Key' => $metka,
|
||||
])
|
||||
->asJson()
|
||||
->post($url, $telo);
|
||||
} catch (\Throwable $e) {
|
||||
// Сюда попадают обрыв связи и выход за предел времени. 🔴 Это НЕ
|
||||
// «звонка не было»: робот мог принять задание и уже звонить.
|
||||
Log::warning('обзвон: робот молчит', [
|
||||
'ruchka' => $ruchka,
|
||||
'metka' => $metka,
|
||||
'prichina' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ObzvonOtvet::neizvestno('робот молчит: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return $this->razobrat($otvet, $ruchka, $metka);
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбор ответа. Успех — ТОЛЬКО условленное слово `{"ok": true}`.
|
||||
*/
|
||||
private function razobrat(Response $otvet, string $ruchka, string $metka): ObzvonOtvet
|
||||
{
|
||||
if ($otvet->serverError()) {
|
||||
// 5xx — робот сломался. Успел ли он до поломки набрать номер, мы не
|
||||
// знаем, поэтому это «не знаю», а не «не было».
|
||||
Log::warning('обзвон: робот ответил поломкой', [
|
||||
'ruchka' => $ruchka,
|
||||
'metka' => $metka,
|
||||
'http' => $otvet->status(),
|
||||
]);
|
||||
|
||||
return ObzvonOtvet::neizvestno('робот ответил HTTP '.$otvet->status());
|
||||
}
|
||||
|
||||
if ($otvet->clientError()) {
|
||||
// 4xx — робот ПОНЯЛ просьбу и отказался. Звонка не было, это его слово.
|
||||
Log::warning('обзвон: робот отказал', [
|
||||
'ruchka' => $ruchka,
|
||||
'metka' => $metka,
|
||||
'http' => $otvet->status(),
|
||||
]);
|
||||
|
||||
return ObzvonOtvet::otkaz('робот отказал: HTTP '.$otvet->status());
|
||||
}
|
||||
|
||||
$telo = $otvet->json();
|
||||
|
||||
if (! is_array($telo) || ! array_key_exists('ok', $telo)) {
|
||||
// 🔴 Ответил 200 и не тем, о чём договаривались. Это НЕ успех (урок
|
||||
// self_render) и НЕ отказ: тело мог покалечить посредник уже после
|
||||
// того, как робот принял задание. Значит — «не знаю».
|
||||
Log::warning('обзвон: ответ без условленного слова ok — робот отвечает не тем, о чём договаривались', [
|
||||
'ruchka' => $ruchka,
|
||||
'metka' => $metka,
|
||||
'http' => $otvet->status(),
|
||||
]);
|
||||
|
||||
return ObzvonOtvet::neizvestno('ответ без условленного слова ok');
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $telo */
|
||||
if ($telo['ok'] !== true) {
|
||||
$prichina = isset($telo['reason']) && is_scalar($telo['reason'])
|
||||
? (string) $telo['reason']
|
||||
: 'без пояснения';
|
||||
|
||||
Log::warning('обзвон: робот сказал нет', [
|
||||
'ruchka' => $ruchka,
|
||||
'metka' => $metka,
|
||||
'prichina' => $prichina,
|
||||
]);
|
||||
|
||||
return ObzvonOtvet::otkaz('робот сказал нет: '.$prichina, $telo);
|
||||
}
|
||||
|
||||
$idZvonka = isset($telo['call_id']) && is_scalar($telo['call_id'])
|
||||
? (string) $telo['call_id']
|
||||
: null;
|
||||
|
||||
return ObzvonOtvet::poluchen('робот ответил', $idZvonka, $telo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Занять одно из `concurrency` мест. Не нашлось — подождать до
|
||||
* `wait_seconds` и попробовать снова. Не дождались — `null`.
|
||||
*
|
||||
* Замок с собственным сроком жизни: упади процесс, держащий место, — место
|
||||
* освободится само, а не пропадёт из обзвона навсегда.
|
||||
*/
|
||||
private function zanyatMesto(): ?Lock
|
||||
{
|
||||
$mest = max(1, $this->concurrency);
|
||||
$srokZamka = max(1, $this->connectTimeout) + max(1, $this->timeout) + 5;
|
||||
$doKogda = microtime(true) + max(0, $this->waitSeconds);
|
||||
|
||||
while (true) {
|
||||
for ($nomer = 0; $nomer < $mest; $nomer++) {
|
||||
$zamok = Cache::lock(self::PREFIKS_MESTA.$nomer, $srokZamka);
|
||||
|
||||
if ($zamok->get()) {
|
||||
return $zamok;
|
||||
}
|
||||
}
|
||||
|
||||
if (microtime(true) >= $doKogda) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usleep(200_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Obzvon;
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
use App\Services\ClientSms\LocalHoursWindow;
|
||||
use App\Services\ClientSms\SmsQuietHours;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
* В какие часы обзвон вправе НАБИРАТЬ номер — задача З-3.1.
|
||||
*
|
||||
* 🔴🔴 ЗДЕСЬ НЕТ И НЕ ДОЛЖНО БЫТЬ НИ ОДНОГО РАСЧЁТА ЧАСА. Правило окна живёт в
|
||||
* {@see SmsQuietHours} и больше нигде (строка Н.3, решение владельца Р76: из двух
|
||||
* путей — «править живой СМС-модуль» или «завести правилу второй дом» — выбран
|
||||
* первый). Этот класс только приносит правилу СВОИ числа и передаёт вопрос
|
||||
* дальше. Скопируй сюда хоть одно сравнение часов — и заведёшь правилу второй
|
||||
* дом, ровно то, что Р76 запретило: разъехавшись, два места дадут звонок,
|
||||
* который по экрану ждёт утра, а по факту уже идёт.
|
||||
*
|
||||
* 🔴 Р76 отдал в общее пользование ПРАВИЛО, А НЕ ЧИСЛА. Границы обзвона —
|
||||
* отдельная настройка портала, а не `client_sms_settings`. Иначе админ, поправив
|
||||
* окно рассылок, молча поправил бы и обзвон.
|
||||
*
|
||||
* Из чего складывается ответ:
|
||||
*
|
||||
* 1. **Общая рамка** 10:00–20:00 по месту ПОЛУЧАТЕЛЯ — админская, потолок для
|
||||
* всех. За неё не выходим никогда.
|
||||
* 2. **Своё окно клиента** — часы его менеджера, названные клиентом в СВОИХ
|
||||
* часах. Рамку они только сужают и никогда не расширяют (Р64): 09:00–22:00
|
||||
* принимается как 10:00–20:00.
|
||||
* 3. 🔴 **Два окна живут в разных часах, и рамка — их пересечение** (Р115).
|
||||
* Набираем только там, где открыты оба ОДНОВРЕМЕННО. Менеджер из Москвы,
|
||||
* работающий 14:00–18:00, и получатель во Владивостоке общего окна не имеют
|
||||
* вовсе — и портал обязан это сказать, а не звонить молча.
|
||||
*
|
||||
* 🔴 **ВХОДЯЩИЕ ЗВОНКИ ОКНОМ НЕ ОГРАНИЧЕНЫ** (решение владельца Р35). Окно задаёт
|
||||
* часы НАШЕГО набора; обратный звонок человека принимается круглосуточно. Поэтому
|
||||
* у этого класса нет и не должно появиться ни одного метода про приём звонка:
|
||||
* спросить его про входящий физически нечем. Сторож на это —
|
||||
* `tests/Feature/Obzvon/ObzvonOknoChasovTest.php`.
|
||||
*
|
||||
* 🔴 Номер с НЕИЗВЕСТНЫМ поясом сюда не доходит: у него нет смещения, и порядок
|
||||
* с ним прежний — сутки ждём уточнения региона, потом не трогаем вовсе (В-116).
|
||||
* Новое условие этого не меняет.
|
||||
*
|
||||
* Границы читаются ОДИН раз на объект и одним запросом — как в
|
||||
* {@see SmsQuietHours}: на 20 000 номеров запрос внутри перебора стал бы
|
||||
* 20 000-м. (Так делает и {@see SrokiZvonka}, но там запрос идёт на каждый
|
||||
* вопрос — здесь нарочно иначе, потому что здесь перебор.)
|
||||
*/
|
||||
final class ObzvonOknoChasov
|
||||
{
|
||||
/** С какого часа обзвон вправе набирать. Ключ настройки портала. */
|
||||
public const KLYUCH_OT_CHASA = 'obzvon_okno_ot_chasa';
|
||||
|
||||
/** До какого часа обзвон вправе набирать (сам этот час уже закрыт). */
|
||||
public const KLYUCH_DO_CHASA = 'obzvon_okno_do_chasa';
|
||||
|
||||
/**
|
||||
* Умолчания — та же общая рамка 10:00–20:00. Это НЕ «значения на всякий
|
||||
* случай»: удали строку настройки кто угодно — портал обязан вернуться к
|
||||
* рамке, а не к «звоним круглосуточно».
|
||||
*/
|
||||
public const OT_CHASA_PO_UMOLCHANIYU = 10;
|
||||
|
||||
public const DO_CHASA_PO_UMOLCHANIYU = 20;
|
||||
|
||||
private ?SmsQuietHours $pravilo = null;
|
||||
|
||||
/**
|
||||
* Можно ли НАБИРАТЬ этот номер прямо сейчас.
|
||||
*
|
||||
* @param int $poyasPoluchatelyaMinut смещение получателя от UTC, минут
|
||||
* @param LocalHoursWindow|null $oknoMenedzhera часы менеджера клиента в ЕГО часах.
|
||||
* Пусто — клиент их не задал, остаётся общая рамка
|
||||
*/
|
||||
public function mozhnoNabirat(
|
||||
int $poyasPoluchatelyaMinut,
|
||||
?LocalHoursWindow $oknoMenedzhera = null,
|
||||
?CarbonImmutable $seychas = null,
|
||||
): bool {
|
||||
return $this->pravilo()->canSendNow($poyasPoluchatelyaMinut, $seychas, $oknoMenedzhera);
|
||||
}
|
||||
|
||||
/**
|
||||
* Когда откроется ближайшее окно для набора. Открыто сейчас — возвращает
|
||||
* текущий миг.
|
||||
*
|
||||
* Пусто означает «звонков не будет вовсе» и бывает только при заданных часах
|
||||
* менеджера: либо они целиком вне рамки, либо не пересекаются с рамкой
|
||||
* получателя из-за поясов. Экран обязан сказать это человеку словами.
|
||||
*/
|
||||
public function kogdaOtkroetsya(
|
||||
int $poyasPoluchatelyaMinut,
|
||||
?LocalHoursWindow $oknoMenedzhera = null,
|
||||
?CarbonImmutable $seychas = null,
|
||||
): ?CarbonImmutable {
|
||||
if ($oknoMenedzhera === null) {
|
||||
return $this->pravilo()->nextWindowOpensAt($poyasPoluchatelyaMinut, $seychas);
|
||||
}
|
||||
|
||||
return $this->pravilo()->nextCommonWindowOpensAt($poyasPoluchatelyaMinut, $oknoMenedzhera, $seychas);
|
||||
}
|
||||
|
||||
/**
|
||||
* Часы менеджера после сужения рамкой (Р64) — для подписи на экране: клиент
|
||||
* задал 09:00–22:00, а звонить будем 10:00–20:00, и человек должен это видеть.
|
||||
*
|
||||
* Пусто — часы заданы целиком вне рамки, звонков не будет.
|
||||
*/
|
||||
public function oknoMenedzheraPosleSuzheniya(LocalHoursWindow $oknoMenedzhera): ?LocalHoursWindow
|
||||
{
|
||||
return $this->pravilo()->narrowedWindow($oknoMenedzhera);
|
||||
}
|
||||
|
||||
/** С какого часа открыта общая рамка обзвона — для подписей на экране. */
|
||||
public function otChasa(): int
|
||||
{
|
||||
return $this->pravilo()->fromHour();
|
||||
}
|
||||
|
||||
/** До какого часа открыта общая рамка обзвона (сам этот час уже закрыт). */
|
||||
public function doChasa(): int
|
||||
{
|
||||
return $this->pravilo()->toHour();
|
||||
}
|
||||
|
||||
/**
|
||||
* То же правило, но со СВОИМИ границами. Собирается один раз на объект;
|
||||
* дальше правило к базе не ходит вовсе.
|
||||
*/
|
||||
private function pravilo(): SmsQuietHours
|
||||
{
|
||||
if ($this->pravilo === null) {
|
||||
[$ot, $do] = $this->granicy();
|
||||
$this->pravilo = SmsQuietHours::withWindow($ot, $do);
|
||||
}
|
||||
|
||||
return $this->pravilo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Границы из настроек портала, одним запросом. Негодное значение молча
|
||||
* заменяется рамкой, а не принимается как есть: пустая строка, буквы или
|
||||
* вывернутое окно «с 20 до 10» — это опечатка в поле ввода, а не решение
|
||||
* владельца, и вести себя по опечатке нельзя, когда цена ошибки — звонок
|
||||
* человеку ночью.
|
||||
*
|
||||
* Осмысленность окна проверяет то же самое правило, а не своя проверка.
|
||||
*
|
||||
* @return array{int, int}
|
||||
*/
|
||||
private function granicy(): array
|
||||
{
|
||||
$stroki = SystemSetting::query()
|
||||
->whereIn('key', [self::KLYUCH_OT_CHASA, self::KLYUCH_DO_CHASA])
|
||||
->pluck('value', 'key');
|
||||
|
||||
$ot = $this->chas($stroki[self::KLYUCH_OT_CHASA] ?? null, self::OT_CHASA_PO_UMOLCHANIYU);
|
||||
$do = $this->chas($stroki[self::KLYUCH_DO_CHASA] ?? null, self::DO_CHASA_PO_UMOLCHANIYU);
|
||||
|
||||
if (! SmsQuietHours::isValidWindow($ot, $do)) {
|
||||
return [self::OT_CHASA_PO_UMOLCHANIYU, self::DO_CHASA_PO_UMOLCHANIYU];
|
||||
}
|
||||
|
||||
return [$ot, $do];
|
||||
}
|
||||
|
||||
private function chas(?string $znachenie, int $umolchanie): int
|
||||
{
|
||||
$znachenie = trim((string) $znachenie);
|
||||
|
||||
if ($znachenie === '' || ! ctype_digit($znachenie)) {
|
||||
return $umolchanie;
|
||||
}
|
||||
|
||||
return (int) $znachenie;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Obzvon;
|
||||
|
||||
/**
|
||||
* Ответ голосового робота на ОДНО обращение портала — задача З-0.3.
|
||||
*
|
||||
* Обычный «читаемый ответ»: исход {@see IskhodObrashcheniya}, человеческое
|
||||
* пояснение для журнала и карточки, внешний номер звонка (его даёт робот) и
|
||||
* разобранное тело ответа.
|
||||
*
|
||||
* 🔴 ГЛАВНОЕ, ЧЕГО ЗДЕСЬ НЕТ И НЕ БУДЕТ — метода `->ok()`. Он звал бы читателя
|
||||
* написать `if (! $otvet->ok()) { повторить(); }`, а это ровно та строчка, из-за
|
||||
* которой живому человеку звонят дважды: «не ok» включает в себя и «не знаю».
|
||||
* Вместо него два вопроса, на которые нельзя ответить не подумав:
|
||||
* • {@see self::poluchen()} — робот сказал условленное слово;
|
||||
* • {@see self::zvonokMogSostoyatsya()} — можно ли вообще считать, что звонка
|
||||
* не было.
|
||||
*/
|
||||
final class ObzvonOtvet
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $telo разобранное тело ответа робота (пусто, если ответа не было)
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly IskhodObrashcheniya $iskhod,
|
||||
public readonly string $poyasnenie,
|
||||
public readonly ?string $vneshniyIdZvonka = null,
|
||||
public readonly array $telo = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Робот ответил условленным словом.
|
||||
*
|
||||
* @param array<string, mixed> $telo
|
||||
*/
|
||||
public static function poluchen(string $poyasnenie, ?string $vneshniyIdZvonka = null, array $telo = []): self
|
||||
{
|
||||
return new self(IskhodObrashcheniya::Poluchen, $poyasnenie, $vneshniyIdZvonka, $telo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Робот сам сказал «нет».
|
||||
*
|
||||
* @param array<string, mixed> $telo
|
||||
*/
|
||||
public static function otkaz(string $poyasnenie, array $telo = []): self
|
||||
{
|
||||
return new self(IskhodObrashcheniya::Otkaz, $poyasnenie, null, $telo);
|
||||
}
|
||||
|
||||
/** Свободного места не нашлось — наружу не ходили. */
|
||||
public static function zanyato(string $poyasnenie): self
|
||||
{
|
||||
return new self(IskhodObrashcheniya::Zanyato, $poyasnenie);
|
||||
}
|
||||
|
||||
/** Шов закрыт нами самими — наружу не ходили, ключ не отправляли. */
|
||||
public static function dverZakryta(string $poyasnenie): self
|
||||
{
|
||||
return new self(IskhodObrashcheniya::DverZakryta, $poyasnenie);
|
||||
}
|
||||
|
||||
/** Мы не знаем, что произошло. Звонок мог состояться. */
|
||||
public static function neizvestno(string $poyasnenie): self
|
||||
{
|
||||
return new self(IskhodObrashcheniya::Neizvestno, $poyasnenie);
|
||||
}
|
||||
|
||||
/** Робот ответил условленным словом — и только это. */
|
||||
public function poluchenLi(): bool
|
||||
{
|
||||
return $this->iskhod === IskhodObrashcheniya::Poluchen;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴🔴 МОГ ЛИ ЗВОНОК СОСТОЯТЬСЯ. Единственный вопрос, который вправе задавать
|
||||
* тот, кто собрался повторять задание.
|
||||
*
|
||||
* `true` — либо робот прямо сказал «принял», либо мы не знаем. В обоих
|
||||
* случаях повтор допустим ТОЛЬКО с тем же опознавательным знаком задания
|
||||
* ({@see ObzvonClient::klyuchZadaniya()}), иначе робот не узнает повтор и
|
||||
* наберёт номер второй раз.
|
||||
*
|
||||
* `false` — звонка доказуемо не было (робот отказал, было занято, дверь
|
||||
* закрыта): повторять безопасно как угодно.
|
||||
*/
|
||||
public function zvonokMogSostoyatsya(): bool
|
||||
{
|
||||
return $this->iskhod === IskhodObrashcheniya::Poluchen
|
||||
|| $this->iskhod === IskhodObrashcheniya::Neizvestno;
|
||||
}
|
||||
|
||||
/** Строка для журнала и карточки: «робот молчит: соединение оборвано». */
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->iskhod->value.': '.$this->poyasnenie;
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,17 @@ final class SchedulerHeartbeatTracker
|
||||
// он ТОЛЬКО по этому списку: команды, которой здесь нет, сторож не видит.
|
||||
'obzvon:chistka' => 1440, // daily
|
||||
'obzvon:chistka-materialov' => 1440, // daily
|
||||
// 🔴 З-0.10. Проверка суточного обхода сторожей на машине робота. Она
|
||||
// сама — присмотр, и без этой строки была бы присмотром БЕЗ ПРИСМОТРА:
|
||||
// портал ждёт стука с машины, а что живо само ожидание — не проверял бы
|
||||
// никто. Здесь она попадает под штатный часовой сторож пульса, то есть
|
||||
// за молчуном второго слоя следит уже построенное.
|
||||
// NB: сам итог с машины лежит в scheduler_heartbeats под именем
|
||||
// 'lena:obhod-storozhey', но в ЭТОТ список не входит нарочно — его
|
||||
// возраст и содержание разбирает lena:proverka-obhoda, и только она
|
||||
// умеет отличить «протух» от «покраснел» и написать на адрес из
|
||||
// настройки, а не на вписанный в код.
|
||||
'lena:proverka-obhoda' => 1440, // daily
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -59,11 +59,14 @@ return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Карточка сделки — два вопроса, которые владелец ещё НЕ закрыл (З-2.4)
|
||||
| Карточка сделки — два вопроса, ЗАКРЫТЫЕ владельцем 06.08.2026 (З-2.4)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 🟡 Значения ниже — ВРЕМЕННЫЕ, поставлены помощником, чтобы код не решал
|
||||
| вопрос владельца молча. Переиграть каждый = поправить одно число здесь.
|
||||
| 🟢 Оба значения ниже — не «пока так», а **решения владельца**: Р120 и Р121
|
||||
| от 06.08.2026. Переспрашивать закрытое не нужно.
|
||||
|
|
||||
| Менять их можно, но только новым решением владельца: числа стоят в одном
|
||||
| месте нарочно, чтобы правка стоила минуту, а не чтобы её делали походя.
|
||||
|
|
||||
*/
|
||||
'kartochka' => [
|
||||
@@ -71,29 +74,42 @@ return [
|
||||
/*
|
||||
* Где кончается «хоть секунда разговора» (Т84а).
|
||||
*
|
||||
* Разговор попадает в ленту, когда трубку сняли (`answered`) И первое
|
||||
* плечо длилось не меньше этого числа секунд. Длительность в базе —
|
||||
* целые секунды (`leg1_duration_sec INTEGER`), так что 0,4 секунды
|
||||
* физически приедет либо нулём, либо единицей — этим числом решается,
|
||||
* что с ней делать.
|
||||
* 🟢 РЕШЕНИЕ ВЛАДЕЛЬЦА Р120 от 06.08.2026: разговором считается контакт
|
||||
* от ОДНОЙ секунды. Закрыто, переспрашивать не нужно.
|
||||
*
|
||||
* 🟡 Временно 1: «ноль секунд — не разговор, одна секунда — разговор».
|
||||
* Владелец вправе поставить 3, 5 или 0.
|
||||
* Разговор попадает в ленту, когда трубку сняли (`answered`) И первое
|
||||
* плечо длилось не меньше этого числа секунд.
|
||||
*
|
||||
* Развилки «0,4 секунды» не существовало вовсе: длительность в базе —
|
||||
* целые секунды (`leg1_duration_sec INTEGER`). Владелец выбирал из
|
||||
* «одна, три или пять» и отрезал три и пять по доводу: короче порога
|
||||
* сброс сливается с недозвоном и становится невидимым, а сброс — это
|
||||
* ОТВЕТ человека, тогда как недозвон — его отсутствие.
|
||||
*
|
||||
* 🪤 ЧЕГО РЕШЕНИЕ НЕ ЗАКРЫВАЕТ: при пороге в секунду лента длиннее, чем
|
||||
* при любом другом, — короткие сбросы тоже строки, и сворачивать их
|
||||
* никто не назначал; случай «соединилось и оборвалось до первого звука»
|
||||
* ни разу не замерен на живых звонках.
|
||||
*/
|
||||
'minimalnaya_dlitelnost_sec' => 1,
|
||||
|
||||
/*
|
||||
* Сколько ПРОШЛЫХ кампаний показывать в карточке (Р113).
|
||||
* Сколько ПРОШЛЫХ списков показывать в карточке (Р113).
|
||||
*
|
||||
* 🟢 РЕШЕНИЕ ВЛАДЕЛЬЦА Р121 от 06.08.2026: показываем ВСЕ прошлые
|
||||
* списки. Закрыто, переспрашивать не нужно. Довод: Р113 требует, чтобы
|
||||
* прошлое было ВИДНО, и прятать часть истории по своему усмотрению мы
|
||||
* не вправе.
|
||||
*
|
||||
* Тот же номер новым списком заводит НОВЫЙ итог (замок
|
||||
* `UNIQUE (tenant_id, campaign_id, phone)`), прежний остаётся историей
|
||||
* и по Р113 в карточке виден.
|
||||
* `UNIQUE (tenant_id, campaign_id, phone)`), прежний остаётся историей.
|
||||
*
|
||||
* null — показывать все. Число N — показывать N самых свежих кампаний.
|
||||
* null — показывать все. Число N — показывать N самых свежих списков.
|
||||
*
|
||||
* 🟡 Временно null: прятать историю по своему усмотрению помощник не
|
||||
* вправе — Р113 требует, чтобы прошлое было ВИДНО. Владелец вправе
|
||||
* поставить 1.
|
||||
* 🪤 ЧЕГО РЕШЕНИЕ НЕ ЗАКРЫВАЕТ: карточка растёт без предела — у номера
|
||||
* в десятке списков лента вытянется. И отдельно: итог привязан к
|
||||
* СПИСКУ, а не к человеку, поэтому при смене владельца номера в
|
||||
* карточке смешается история двух РАЗНЫХ людей. Это не обсуждали вовсе.
|
||||
*/
|
||||
'kampaniy_v_kartochke' => null,
|
||||
],
|
||||
|
||||
@@ -316,8 +316,31 @@ return [
|
||||
// Автоподбор self-render: свой рендер-сервис на виртуалке (замена xf4, за флагом autopodbor.self_render).
|
||||
// Секрет портал↔виртуалка и адрес — ТОЛЬКО в .env. Без них SelfRenderClient молча пусто (деградация).
|
||||
'self_render' => [
|
||||
// 🔴 Р126: адрес обязан быть `https://`. Тайный ключ едет ЗАГОЛОВКОМ X-Render-Key в
|
||||
// каждом запросе — по `http://` он поехал бы открытым текстом и молча. Адрес без
|
||||
// шифрования портал не принимает: дверь закрыта, поиск отдаёт пусто и говорит об этом
|
||||
// в журнал. Исключение ровно одно — разработчик на своём компьютере, и оно НЕ
|
||||
// настраивается отсюда: тумблера «можно без шифра» тут нет намеренно, такой тумблер и
|
||||
// был бы той самой опечаткой. Как устроено — см. `DverRendera::svoyaMashina()`.
|
||||
'endpoint' => env('SELF_RENDER_ENDPOINT', ''),
|
||||
'key' => env('SELF_RENDER_KEY', ''),
|
||||
// 🔴 Р126. Куда клиенту ВООБЩЕ разрешено ходить с тайным ключом. Опечатка в
|
||||
// SELF_RENDER_ENDPOINT увела бы запрос в чужой интернет — и тайный ключ уехал бы чужому
|
||||
// человеку, молча и навсегда. Хост не из этого списка = дверь закрыта, запрос не
|
||||
// отправлен, ключ никуда не ушёл. Пустой список = закрыто ВСЁ (fail-closed).
|
||||
//
|
||||
// 🔴 Умолчание НЕ пустое намеренно: на боевом этой строки в .env нет, а «Поиск клиентов»
|
||||
// работает и зарабатывает прямо сейчас. Пустое умолчание закрыло бы его в момент выката.
|
||||
// Взято по замеру ПИЛОТ.md: боевая виртуалка-рендер `https://51-250-1-97.sslip.io/render`
|
||||
// (51.250.1.97). Набор тот же, что уже принят владельцем для OBZVON_ALLOWED_HOSTS —
|
||||
// это ОДНА И ТА ЖЕ машина.
|
||||
'allowed_hosts' => array_values(array_filter(
|
||||
array_map('trim', explode(',', (string) env(
|
||||
'SELF_RENDER_ALLOWED_HOSTS',
|
||||
'51.250.1.97,51-250-1-97.sslip.io,127.0.0.1,localhost',
|
||||
))),
|
||||
static fn (string $v): bool => $v !== '',
|
||||
)),
|
||||
// Параллельность запросов к виртуалке (браузерная параллельность — внутри виртуалки, §12).
|
||||
'concurrency' => (int) env('SELF_RENDER_CONCURRENCY', 8),
|
||||
// Плитка балансов (16.07): URL health-check виртуалки. Пусто → пингуем endpoint.
|
||||
@@ -327,6 +350,12 @@ return [
|
||||
// Плитка балансов (17.07): прокси автоподбора (Proxy.Market, мобильный Tele2). «Баланс» = срок
|
||||
// действия. Живость проверяет виртуалка-рендер (у неё логин/пароль прокси) через /proxy-check.
|
||||
// Ключ виртуалки берём из services.self_render.key. Срок — руками в .env при продлении.
|
||||
//
|
||||
// 🔴 Р126, третья дверь (решение владельца 07.08.2026). Ключ тут ТОТ ЖЕ, что у рендера, а
|
||||
// адрес — ДРУГОЙ (PROXY_MARKET_CHECK_URL). Поэтому проба проходит ту же дверь `DverRendera`:
|
||||
// только https:// и только хост из `self_render.allowed_hosts` — списка своего у неё нет
|
||||
// намеренно, список один на ключ (два списка на один ключ разъехались бы). Дверь закрыта =
|
||||
// плитка СЕРАЯ («не знаем»), а не красная («прокси кончились»), — красным врать нельзя.
|
||||
'proxy_market' => [
|
||||
'check_url' => env('PROXY_MARKET_CHECK_URL'), // https://51-250-1-97.sslip.io/proxy-check
|
||||
'expires_at' => env('PROXY_MARKET_EXPIRES_AT'), // 2026-09-03 (продлён 05.08.2026)
|
||||
@@ -440,4 +469,67 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Обзвон: шов «портал зовёт голосового робота» (З-0.3, решение владельца Р84).
|
||||
//
|
||||
// 🔴 Робот живёт на ОТДЕЛЬНОЙ машине и делит железо с боевым рендером «Поиска
|
||||
// клиентов» — это живые деньги живых клиентов. Поэтому предел одновременных
|
||||
// заданий и пределы времени стоят здесь С САМОГО НАЧАЛА, а не «когда-нибудь
|
||||
// потом»: голос не имеет права уронить чужой боевой рендер.
|
||||
//
|
||||
// Адрес и тайный ключ — ТОЛЬКО в .env. Пусто = дверь закрыта: {@see ObzvonClient}
|
||||
// наружу не ходит, звонка точно не было, и он это говорит вслух (мягкая
|
||||
// деградация — та же, что у self_render без ключа).
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
'obzvon' => [
|
||||
// БАЗОВЫЙ адрес приёмника заданий на машине робота (без хвоста ручки):
|
||||
// клиент сам добавит /pozvonit, /sostoyanie, /ostanovit. Сам приёмник —
|
||||
// задача З-0.6, его ещё нет; портальная половина от этого не зависит.
|
||||
//
|
||||
// 🔴 Р123: адрес обязан быть `https://`. Тайный ключ едет ЗАГОЛОВКОМ в
|
||||
// каждом запросе — по `http://` он поехал бы открытым текстом и молча.
|
||||
// Адрес без шифрования портал не принимает: дверь закрыта, звонка нет.
|
||||
// Исключение ровно одно — разработчик на своём компьютере, и оно НЕ
|
||||
// настраивается отсюда: тумблера «можно без шифра» тут нет намеренно,
|
||||
// такой тумблер и был бы той самой опечаткой. Как устроено исключение —
|
||||
// см. `ObzvonClient::svoyaMashina()`.
|
||||
'endpoint' => env('OBZVON_ENDPOINT', ''),
|
||||
'key' => env('OBZVON_KEY', ''),
|
||||
|
||||
// Сколько заданий разрешено держать у робота ОДНОВРЕМЕННО.
|
||||
// 🔴 Предел общий на машину, а не «по числу в памяти работника очереди»:
|
||||
// он взят именованными замками кэша (см. ObzvonClient), поэтому два и
|
||||
// более работников очереди в РАЗНЫХ процессах делят одни и те же места.
|
||||
// Чего он НЕ ограничивает — см. шапку ObzvonClient, там сказано прямо.
|
||||
'concurrency' => (int) env('OBZVON_CONCURRENCY', 4),
|
||||
|
||||
// Сколько СЕКУНД лишнее задание согласно ждать свободного места. Дождалось —
|
||||
// пошло; не дождалось — исход «робот занят»: наружу не ходили, звонка точно
|
||||
// не было, повторить позже безопасно. 0 = не ждать вовсе (так гоняем тесты).
|
||||
'wait_seconds' => (int) env('OBZVON_WAIT_SECONDS', 10),
|
||||
|
||||
// Два предела времени, а не один — и это не перестраховка.
|
||||
// connect_timeout — дозвониться до машины робота;
|
||||
// timeout — ВЕСЬ обмен целиком, вместе с чтением ответа.
|
||||
// Второй нужен ровно против случая «робот отдаёт ответ по байту бесконечно»:
|
||||
// соединение установлено, дальше чтение висит вечно и держит работника
|
||||
// очереди. Один только connect_timeout такой случай НЕ ловит.
|
||||
'connect_timeout' => (int) env('OBZVON_CONNECT_TIMEOUT', 5),
|
||||
'timeout' => (int) env('OBZVON_TIMEOUT', 15),
|
||||
|
||||
// 🔴 Куда клиенту ВООБЩЕ разрешено ходить с тайным ключом. Опечатка в
|
||||
// OBZVON_ENDPOINT уводит запрос в чужой интернет — и тайный ключ уезжает
|
||||
// чужому человеку, молча и навсегда. Хост не из этого списка = дверь
|
||||
// закрыта, запрос не отправлен, ключ никуда не ушёл.
|
||||
// Пустой список = закрыто ВСЁ (fail-closed, а не «раз пусто — значит можно»).
|
||||
// Переезд робота на другой адрес — осознанная правка .env в двух местах.
|
||||
'allowed_hosts' => array_values(array_filter(
|
||||
array_map('trim', explode(',', (string) env(
|
||||
'OBZVON_ALLOWED_HOSTS',
|
||||
'51.250.1.97,51-250-1-97.sslip.io,127.0.0.1,localhost',
|
||||
))),
|
||||
static fn (string $v): bool => $v !== '',
|
||||
)),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Сторожа машины робота (З-0.10)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| На машине робота (51.250.1.97) живут сторожа. Они проверяют, что уборка
|
||||
| записей телефонных разговоров с ЖИВЫМИ ЛЮДЬМИ вправду работает и что слова
|
||||
| этих людей не текут в журналы. Раз в сутки машина сама гоняет их всех и
|
||||
| стучится сюда с итогом (решение владельца Р116: стучится машина, портал на
|
||||
| машину не ходит — ключа от машины с чужими голосами у портала быть не должно).
|
||||
|
|
||||
| 🔴 Тайного слова в коде НЕТ и быть не может. Только окружение.
|
||||
|
|
||||
| Отдельный файл настроек, а не блок в services.php, нарочно: services.php
|
||||
| правят сразу несколько смен, и лишний повод столкнуться нам не нужен.
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
| Тайное слово, которым машина подписывает тело итога (HMAC-SHA256).
|
||||
|
|
||||
| 🔴 Слово в АДРЕС не кладём. Адреса целиком попадают в журналы nginx, и
|
||||
| слово оттуда утечёт кому угодно. Тело с подписью в журналы не
|
||||
| попадает. Это и есть разница с приёмом лидов от поставщика, где
|
||||
| слово в адресе осталось от старого уговора.
|
||||
|
|
||||
| Пустое или короче 32 знаков — приём выключен полностью, любой стук
|
||||
| получит 404. Так дорога не открывается «сама собой» на новой машине.
|
||||
*/
|
||||
'tainoe_slovo' => (string) env('LENA_STOROZHA_SLOVO', ''),
|
||||
|
||||
/*
|
||||
| Кому позволено стучать: адреса или подсети через запятую.
|
||||
| Пусто на бою — приём закрыт (fail-closed), как у вебхука поставщика.
|
||||
| Пусто НЕ на бою — пускаем всех: иначе не проверить на своей машине.
|
||||
*/
|
||||
'adresa' => (string) env('LENA_STOROZHA_ADRESA', ''),
|
||||
|
||||
/*
|
||||
| Сколько сторожей мы ЖДЁМ в итоге.
|
||||
|
|
||||
| 🔴 Это число закрывает целый род беды: «сторожей стало четыре, а стучит
|
||||
| по-прежнему три». Обход на машине ищет сторожей списком файлов и
|
||||
| называет, скольких нашёл. Не сойдётся с этим числом в любую сторону —
|
||||
| итог считается красным и уходит письмо. Прибавили сторожа на машине —
|
||||
| обязаны прибавить и здесь, руками, осознанно.
|
||||
*/
|
||||
'ozhidaem_storozhey' => (int) env('LENA_STOROZHA_SKOLKO', 3),
|
||||
|
||||
/*
|
||||
| Сколько часов итог считается свежим.
|
||||
|
|
||||
| 🔴 Главное число всей затеи. Зелёный итог недельной давности хуже
|
||||
| красного сегодняшнего: он усыпляет. Обход ходит раз в сутки, 26 часов
|
||||
| дают два часа запаса на сдвиг расписания и на долгий обход, но не
|
||||
| дают пропустить ЦЕЛЫЙ пропущенный день.
|
||||
*/
|
||||
'svezhest_chasov' => (int) env('LENA_STOROZHA_SVEZHEST_CHASOV', 26),
|
||||
|
||||
/*
|
||||
| Имя строки в scheduler_heartbeats, под которым лежит итог обхода.
|
||||
| Своей таблицы не заводим: у портала уже есть таблица отметок пульса,
|
||||
| и итог обхода — ровно такая же отметка.
|
||||
*/
|
||||
'imya_otmetki' => 'lena:obhod-storozhey',
|
||||
];
|
||||
@@ -105,7 +105,10 @@ export interface AdminTenant {
|
||||
balance_rub: string;
|
||||
balance_leads: number;
|
||||
is_trial: boolean;
|
||||
/** Последний вход из журнала auth_log за 90 дней; null — входов не было. */
|
||||
last_activity_at: string | null;
|
||||
/** Состояние по порогам 3/14 суток — для цветной точки в списке. */
|
||||
aliveness_state: ApiAlivenessState;
|
||||
tariff_id: number | null;
|
||||
tariff_name: string | null;
|
||||
/** price_monthly активного тарифа если не-trial; иначе null. */
|
||||
@@ -179,13 +182,39 @@ export interface ApiTenantBalanceTx {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ApiTenantActivityEvent {
|
||||
id: number;
|
||||
export type ApiAlivenessState = 'active' | 'cooling' | 'lost' | 'never_logged_in' | 'no_data';
|
||||
|
||||
export interface ApiTenantAliveness {
|
||||
state: ApiAlivenessState;
|
||||
last_login_at: string | null;
|
||||
days_since_login: number | null;
|
||||
last_login_ip: string | null;
|
||||
last_login_user_agent: string | null;
|
||||
days: Array<{ date: string; visits: number }>;
|
||||
counters: {
|
||||
visits_30d: number;
|
||||
active_days_30d: number;
|
||||
actions_30d: number;
|
||||
failed_logins_30d: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiTenantActivityItem {
|
||||
id: string;
|
||||
at: string;
|
||||
kind: 'project' | 'deal' | 'money';
|
||||
event: string;
|
||||
deal_id: number;
|
||||
actor_email: string | null;
|
||||
context: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
actor: string | null;
|
||||
subject: string | null;
|
||||
amount_rub: string | null;
|
||||
bulk_count: number;
|
||||
}
|
||||
|
||||
export interface ApiTenantActivityPage {
|
||||
items: ApiTenantActivityItem[];
|
||||
offset: number;
|
||||
limit: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
interface ApiTenantMetrics {
|
||||
@@ -201,7 +230,8 @@ export interface AdminTenantDetailResponse {
|
||||
users: ApiTenantUser[];
|
||||
projects: ApiTenantProject[];
|
||||
balance_history: ApiTenantBalanceTx[];
|
||||
activity: ApiTenantActivityEvent[];
|
||||
aliveness: ApiTenantAliveness;
|
||||
activity: ApiTenantActivityPage;
|
||||
metrics: ApiTenantMetrics;
|
||||
}
|
||||
|
||||
@@ -212,6 +242,18 @@ export async function getAdminTenantDetail(subdomain: string): Promise<AdminTena
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getAdminTenantActivity(
|
||||
subdomain: string,
|
||||
offset: number,
|
||||
limit = 50,
|
||||
): Promise<ApiTenantActivityPage> {
|
||||
const { data } = await apiClient.get<ApiTenantActivityPage>(
|
||||
`/api/admin/tenants/${encodeURIComponent(subdomain)}/activity`,
|
||||
{ params: { offset, limit } },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
// === SaaS-admin → Биллинг: aggregates пополнений/списаний ===
|
||||
|
||||
export interface ApiAdminBillingTenant {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { TenantActivityItem, TenantAliveness } from '../../../composables/mockTenantDetail';
|
||||
import { formatDate } from '../../../composables/adminTenantDetailFormatters';
|
||||
import {
|
||||
activityPhrase,
|
||||
alivenessColor,
|
||||
alivenessTitle,
|
||||
deviceLabel,
|
||||
} from '../../../composables/tenantActivityFormatters';
|
||||
|
||||
const STRIP_DAYS = 90;
|
||||
|
||||
const props = defineProps<{
|
||||
aliveness: TenantAliveness;
|
||||
items: TenantActivityItem[];
|
||||
hasMore: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{ 'load-more': [] }>();
|
||||
|
||||
const title = computed(() => alivenessTitle(props.aliveness.state, props.aliveness.daysSinceLogin));
|
||||
const color = computed(() => alivenessColor(props.aliveness.state));
|
||||
const device = computed(() => deviceLabel(props.aliveness.lastLoginUserAgent));
|
||||
|
||||
/**
|
||||
* Полоска всегда ровно 90 суток, самые старые слева. Бэкенд присылает только
|
||||
* дни с визитами — пустые достраиваем здесь, иначе по ответу не отличить
|
||||
* «визитов не было» от «данных не прислали».
|
||||
*/
|
||||
const strip = computed(() => {
|
||||
const visitsByDate = new Map(props.aliveness.days.map((d) => [d.date, d.visits]));
|
||||
const today = new Date();
|
||||
const out: Array<{ date: string; visits: number }> = [];
|
||||
|
||||
for (let back = STRIP_DAYS - 1; back >= 0; back--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - back);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
out.push({ date: key, visits: visitsByDate.get(key) ?? 0 });
|
||||
}
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
function stripClass(visits: number): string {
|
||||
if (visits === 0) return 'strip-day strip-none';
|
||||
if (visits === 1) return 'strip-day strip-one';
|
||||
return 'strip-day strip-many';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-4" data-testid="pane-activity">
|
||||
<v-alert
|
||||
:type="color === 'grey' ? 'info' : (color as 'success' | 'warning' | 'error')"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
>
|
||||
<div class="text-subtitle-1 font-weight-medium">{{ title }}</div>
|
||||
<div v-if="aliveness.lastLoginAt !== null" class="text-caption mt-1">
|
||||
последний вход — {{ formatDate(aliveness.lastLoginAt) }}
|
||||
· {{ device }}
|
||||
<span v-if="aliveness.lastLoginIp !== null"> · {{ aliveness.lastLoginIp }}</span>
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<div class="strip mt-4">
|
||||
<span
|
||||
v-for="d in strip"
|
||||
:key="d.date"
|
||||
data-testid="strip-day"
|
||||
:class="stripClass(d.visits)"
|
||||
:title="`${d.date} — визитов: ${d.visits}`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="counters mt-4">
|
||||
<div class="counter">
|
||||
<div class="text-caption text-medium-emphasis">Визитов за 30 дней</div>
|
||||
<div class="num text-h6">{{ aliveness.visits30d }}</div>
|
||||
</div>
|
||||
<div class="counter">
|
||||
<div class="text-caption text-medium-emphasis">Дней с визитами</div>
|
||||
<div class="num text-h6">{{ aliveness.activeDays30d }}</div>
|
||||
</div>
|
||||
<div class="counter">
|
||||
<div class="text-caption text-medium-emphasis">Действий</div>
|
||||
<div class="num text-h6">{{ aliveness.actions30d }}</div>
|
||||
</div>
|
||||
<div class="counter">
|
||||
<div class="text-caption text-medium-emphasis">Не смог войти</div>
|
||||
<div
|
||||
class="num text-h6"
|
||||
data-testid="failed-logins"
|
||||
:class="{ 'text-error': aliveness.failedLogins30d > 0 }"
|
||||
>
|
||||
{{ aliveness.failedLogins30d }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
|
||||
<p v-if="items.length === 0" class="text-body-2 text-medium-emphasis">
|
||||
Ничего не менял за это время.
|
||||
</p>
|
||||
|
||||
<ul v-else class="feed">
|
||||
<li v-for="i in items" :key="i.id" class="feed-item">
|
||||
<div class="feed-time num text-medium-emphasis">{{ formatDate(i.at) }}</div>
|
||||
<div class="feed-text text-body-2">{{ activityPhrase(i) }}</div>
|
||||
<div class="feed-actor text-caption text-medium-emphasis">{{ i.actor ?? '—' }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="hasMore" class="mt-3">
|
||||
<v-btn variant="text" size="small" data-testid="load-more" @click="$emit('load-more')">
|
||||
Показать ещё
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
.strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
}
|
||||
.strip-day {
|
||||
width: 8px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.strip-none {
|
||||
background: #e8e4d9;
|
||||
}
|
||||
.strip-one {
|
||||
background: #7fbfa8;
|
||||
}
|
||||
.strip-many {
|
||||
background: #0f6e56;
|
||||
}
|
||||
.counters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.feed {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.feed-item {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: baseline;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0ede4;
|
||||
}
|
||||
.feed-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
txTypeColor,
|
||||
txTypeLabel,
|
||||
} from '../../../composables/adminTenantDetailFormatters';
|
||||
import TenantActivityPane from './TenantActivityPane.vue';
|
||||
|
||||
type TabKey = 'finance' | 'users' | 'projects' | 'activity';
|
||||
|
||||
@@ -17,6 +18,7 @@ defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:activeTab': [value: TabKey];
|
||||
'load-more-activity': [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -75,7 +77,7 @@ const emit = defineEmits<{
|
||||
<th>Пользователь</th>
|
||||
<th>Email</th>
|
||||
<th>Роль</th>
|
||||
<th>Последняя активность</th>
|
||||
<th>Последний вход</th>
|
||||
<th>Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -86,7 +88,9 @@ const emit = defineEmits<{
|
||||
<td>
|
||||
<v-chip size="x-small" variant="tonal">{{ roleLabel(u.role) }}</v-chip>
|
||||
</td>
|
||||
<td class="num text-medium-emphasis">{{ formatDate(u.last_active_at) }}</td>
|
||||
<td class="num text-medium-emphasis">
|
||||
{{ u.last_login_at !== '' ? formatDate(u.last_login_at) : '—' }}
|
||||
</td>
|
||||
<td>
|
||||
<v-chip size="x-small" variant="tonal" :color="u.is_active ? 'success' : 'error'">
|
||||
{{ u.is_active ? 'активен' : 'отключён' }}
|
||||
@@ -126,20 +130,13 @@ const emit = defineEmits<{
|
||||
</v-table>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'activity'" class="pa-4" data-testid="pane-activity">
|
||||
<ul class="activity-list">
|
||||
<li v-for="ev in tenant.activity" :key="ev.id" class="activity-item">
|
||||
<div class="act-time num text-medium-emphasis">{{ formatDate(ev.created_at) }}</div>
|
||||
<div class="act-body">
|
||||
<div class="act-event">
|
||||
<code class="font-mono">{{ ev.event }}</code>
|
||||
<span class="act-actor text-caption text-medium-emphasis">· {{ ev.actor }}</span>
|
||||
</div>
|
||||
<div class="act-summary text-body-2">{{ ev.summary }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<TenantActivityPane
|
||||
v-else-if="activeTab === 'activity'"
|
||||
:aliveness="tenant.aliveness"
|
||||
:items="tenant.activity"
|
||||
:has-more="tenant.activityHasMore"
|
||||
@load-more="emit('load-more-activity')"
|
||||
/>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
@@ -149,31 +146,7 @@ const emit = defineEmits<{
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
.panel {
|
||||
background: #fff;
|
||||
}
|
||||
.activity-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.activity-item {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: 16px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f0ede4;
|
||||
}
|
||||
.activity-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.act-event {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.act-actor {
|
||||
margin-left: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTenant, TenantStatus } from '../../../composables/mockTenants';
|
||||
import { alivenessShortLabel } from '../../../composables/tenantActivityFormatters';
|
||||
|
||||
defineProps<{
|
||||
tenants: AdminTenant[];
|
||||
@@ -78,6 +79,12 @@ function statusColor(s: TenantStatus): string {
|
||||
<span v-else class="text-medium-emphasis">—</span>
|
||||
</template>
|
||||
<template #[`item.activitySince`]="{ item }: { item: AdminTenant }">
|
||||
<span
|
||||
data-testid="aliveness-dot"
|
||||
class="dot"
|
||||
:class="`dot-${item.alivenessState}`"
|
||||
:title="alivenessShortLabel(item.alivenessState)"
|
||||
/>
|
||||
<span class="num text-medium-emphasis">{{ item.activitySince }}</span>
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }: { item: AdminTenant }">
|
||||
@@ -135,4 +142,25 @@ function statusColor(s: TenantStatus): string {
|
||||
font-weight: 500;
|
||||
color: #081319;
|
||||
}
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dot-active {
|
||||
background: #0f6e56;
|
||||
}
|
||||
.dot-cooling {
|
||||
background: #c98a1b;
|
||||
}
|
||||
.dot-lost,
|
||||
.dot-never_logged_in {
|
||||
background: #b3261e;
|
||||
}
|
||||
.dot-no_data {
|
||||
background: #c4c0b6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type {
|
||||
AdminTenant as ApiAdminTenant,
|
||||
AdminTenantDetailResponse,
|
||||
ApiTenantActivityEvent,
|
||||
ApiTenantActivityItem,
|
||||
ApiTenantAliveness,
|
||||
ApiTenantBalanceTx,
|
||||
ApiTenantProject,
|
||||
ApiTenantUser,
|
||||
} from '../api/admin';
|
||||
import type {
|
||||
AdminTenantDetail,
|
||||
TenantActivityEvent,
|
||||
TenantActivityItem,
|
||||
TenantAliveness,
|
||||
TenantBalanceTx,
|
||||
TenantProject,
|
||||
TenantUser,
|
||||
@@ -73,7 +75,9 @@ function mapUser(u: ApiTenantUser): TenantUser {
|
||||
// role в schema users отсутствует — на MVP единая роль 'manager'.
|
||||
// Расширение в legal_entities/user_roles вынесено в Post-MVP.
|
||||
role: 'manager',
|
||||
last_active_at: u.last_active_at ?? '',
|
||||
// users.last_active_at не заполняется никем (замерено 07.08.2026 —
|
||||
// ноль из одиннадцати), поэтому показываем живой last_login_at.
|
||||
last_login_at: u.last_login_at ?? '',
|
||||
is_active: u.is_active,
|
||||
};
|
||||
}
|
||||
@@ -112,28 +116,32 @@ function mapBalanceTx(tx: ApiTenantBalanceTx): TenantBalanceTx {
|
||||
};
|
||||
}
|
||||
|
||||
function mapActivity(ev: ApiTenantActivityEvent): TenantActivityEvent {
|
||||
const summary = buildActivitySummary(ev);
|
||||
function mapAliveness(a: ApiTenantAliveness): TenantAliveness {
|
||||
return {
|
||||
id: ev.id,
|
||||
event: ev.event,
|
||||
actor: ev.actor_email ?? 'system',
|
||||
summary,
|
||||
created_at: ev.created_at,
|
||||
state: a.state,
|
||||
lastLoginAt: a.last_login_at,
|
||||
daysSinceLogin: a.days_since_login,
|
||||
lastLoginIp: a.last_login_ip,
|
||||
lastLoginUserAgent: a.last_login_user_agent,
|
||||
days: a.days,
|
||||
visits30d: a.counters.visits_30d,
|
||||
activeDays30d: a.counters.active_days_30d,
|
||||
actions30d: a.counters.actions_30d,
|
||||
failedLogins30d: a.counters.failed_logins_30d,
|
||||
};
|
||||
}
|
||||
|
||||
function buildActivitySummary(ev: ApiTenantActivityEvent): string {
|
||||
if (ev.context && typeof ev.context === 'object') {
|
||||
const ctx = ev.context as Record<string, unknown>;
|
||||
if (typeof ctx.from === 'string' && typeof ctx.to === 'string') {
|
||||
return `Сделка #${ev.deal_id}: ${ctx.from} → ${ctx.to}`;
|
||||
}
|
||||
}
|
||||
if (ev.deal_id > 0) {
|
||||
return `Сделка #${ev.deal_id}`;
|
||||
}
|
||||
return ev.event;
|
||||
export function mapTenantActivityItem(i: ApiTenantActivityItem): TenantActivityItem {
|
||||
return {
|
||||
id: i.id,
|
||||
at: i.at,
|
||||
kind: i.kind,
|
||||
event: i.event,
|
||||
actor: i.actor,
|
||||
subject: i.subject,
|
||||
amountRub: i.amount_rub !== null ? parseFloat(i.amount_rub) : null,
|
||||
bulkCount: i.bulk_count,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAdminTenantDetail(api: AdminTenantDetailResponse, now: Date = new Date()): AdminTenantDetail {
|
||||
@@ -153,6 +161,7 @@ export function mapAdminTenantDetail(api: AdminTenantDetailResponse, now: Date =
|
||||
todayActual: api.metrics.leads_today,
|
||||
mrrRub: api.tenant.mrr_rub !== null ? parseFloat(api.tenant.mrr_rub) : null,
|
||||
activitySince: activitySinceText(api.tenant.last_activity_at, now),
|
||||
alivenessState: api.aliveness.state,
|
||||
// расширение AdminTenantDetail
|
||||
contact_email: api.tenant.contact_email,
|
||||
contact_phone: '',
|
||||
@@ -161,7 +170,9 @@ export function mapAdminTenantDetail(api: AdminTenantDetailResponse, now: Date =
|
||||
users: api.users.map(mapUser),
|
||||
projects: api.projects.map(mapProject),
|
||||
balanceHistory: api.balance_history.map(mapBalanceTx),
|
||||
activity: api.activity.map(mapActivity),
|
||||
aliveness: mapAliveness(api.aliveness),
|
||||
activity: api.activity.items.map(mapTenantActivityItem),
|
||||
activityHasMore: api.activity.has_more,
|
||||
leadsThisMonth: api.metrics.leads_this_month,
|
||||
leadsThisWeek: api.metrics.leads_this_week,
|
||||
avgLeadCost: api.metrics.avg_lead_cost_rub ?? 0,
|
||||
|
||||
@@ -49,7 +49,9 @@ function deriveTariff(name: string | null): TenantTariff {
|
||||
}
|
||||
|
||||
function formatRelative(iso: string | null, now: Date = new Date()): string {
|
||||
if (iso === null) return '—';
|
||||
// Пусто — значит за девяносто дней (окно запроса) входов не было. Прочерк
|
||||
// читался бы как «неизвестно», а это точный ответ, просто неутешительный.
|
||||
if (iso === null) return 'больше 90 дней';
|
||||
const dt = new Date(iso);
|
||||
const minutes = Math.max(0, Math.floor((now.getTime() - dt.getTime()) / 60000));
|
||||
if (minutes < 60) return `${minutes} мин назад`;
|
||||
@@ -72,5 +74,6 @@ export function mapApiAdminTenant(api: ApiAdminTenant, now: Date = new Date()):
|
||||
todayActual: 0, // нет в API (потребует aggregate query)
|
||||
mrrRub: api.mrr_rub !== null ? parseFloat(api.mrr_rub) : null,
|
||||
activitySince: formatRelative(api.last_activity_at, now),
|
||||
alivenessState: api.aliveness_state,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ export interface TenantUser {
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: 'admin' | 'manager' | 'reader';
|
||||
last_active_at: string;
|
||||
/** Пусто — входов не было. users.last_active_at не заполняется никем. */
|
||||
last_login_at: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
@@ -34,12 +35,30 @@ export interface TenantBalanceTx {
|
||||
created_at: string; // ISO date
|
||||
}
|
||||
|
||||
export interface TenantActivityEvent {
|
||||
id: number;
|
||||
event: string; // 'login_success' | 'deal.created' | 'webhook.received' | 'tariff.changed'
|
||||
actor: string; // email or 'system'
|
||||
summary: string;
|
||||
created_at: string;
|
||||
export type AlivenessState = 'active' | 'cooling' | 'lost' | 'never_logged_in' | 'no_data';
|
||||
|
||||
export interface TenantAliveness {
|
||||
state: AlivenessState;
|
||||
lastLoginAt: string | null;
|
||||
daysSinceLogin: number | null;
|
||||
lastLoginIp: string | null;
|
||||
lastLoginUserAgent: string | null;
|
||||
days: Array<{ date: string; visits: number }>;
|
||||
visits30d: number;
|
||||
activeDays30d: number;
|
||||
actions30d: number;
|
||||
failedLogins30d: number;
|
||||
}
|
||||
|
||||
export interface TenantActivityItem {
|
||||
id: string;
|
||||
at: string;
|
||||
kind: 'project' | 'deal' | 'money';
|
||||
event: string;
|
||||
actor: string | null;
|
||||
subject: string | null;
|
||||
amountRub: number | null;
|
||||
bulkCount: number;
|
||||
}
|
||||
|
||||
export interface AdminTenantDetail extends AdminTenant {
|
||||
@@ -50,7 +69,9 @@ export interface AdminTenantDetail extends AdminTenant {
|
||||
users: TenantUser[];
|
||||
projects: TenantProject[];
|
||||
balanceHistory: TenantBalanceTx[];
|
||||
activity: TenantActivityEvent[];
|
||||
aliveness: TenantAliveness;
|
||||
activity: TenantActivityItem[];
|
||||
activityHasMore: boolean;
|
||||
leadsThisMonth: number;
|
||||
leadsThisWeek: number;
|
||||
avgLeadCost: number;
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface AdminTenant {
|
||||
todayActual: number;
|
||||
mrrRub: number | null;
|
||||
activitySince: string; // '28 мин назад'
|
||||
/** Жив ли клиент по порогам 3/14 суток — красит точку рядом с активностью. */
|
||||
alivenessState: 'active' | 'cooling' | 'lost' | 'never_logged_in' | 'no_data';
|
||||
}
|
||||
|
||||
export const MOCK_TENANTS: AdminTenant[] = [
|
||||
@@ -37,6 +39,7 @@ export const MOCK_TENANTS: AdminTenant[] = [
|
||||
todayActual: 11,
|
||||
mrrRub: 990,
|
||||
activitySince: '28 мин назад',
|
||||
alivenessState: 'active',
|
||||
},
|
||||
{
|
||||
id: 51,
|
||||
@@ -51,6 +54,7 @@ export const MOCK_TENANTS: AdminTenant[] = [
|
||||
todayActual: 14,
|
||||
mrrRub: 990,
|
||||
activitySince: '14 мин назад',
|
||||
alivenessState: 'lost',
|
||||
},
|
||||
{
|
||||
id: 67,
|
||||
@@ -65,6 +69,7 @@ export const MOCK_TENANTS: AdminTenant[] = [
|
||||
todayActual: 6,
|
||||
mrrRub: 2990,
|
||||
activitySince: '1 ч назад',
|
||||
alivenessState: 'active',
|
||||
},
|
||||
{
|
||||
id: 88,
|
||||
@@ -79,6 +84,7 @@ export const MOCK_TENANTS: AdminTenant[] = [
|
||||
todayActual: 0,
|
||||
mrrRub: null,
|
||||
activitySince: '3 ч назад',
|
||||
alivenessState: 'active',
|
||||
},
|
||||
{
|
||||
id: 92,
|
||||
@@ -93,6 +99,7 @@ export const MOCK_TENANTS: AdminTenant[] = [
|
||||
todayActual: 0,
|
||||
mrrRub: 990,
|
||||
activitySince: '2 дня назад',
|
||||
alivenessState: 'active',
|
||||
},
|
||||
{
|
||||
id: 105,
|
||||
@@ -107,6 +114,7 @@ export const MOCK_TENANTS: AdminTenant[] = [
|
||||
todayActual: 0,
|
||||
mrrRub: null,
|
||||
activitySince: '7 дней назад',
|
||||
alivenessState: 'active',
|
||||
},
|
||||
{
|
||||
id: 117,
|
||||
@@ -121,6 +129,7 @@ export const MOCK_TENANTS: AdminTenant[] = [
|
||||
todayActual: 31,
|
||||
mrrRub: 9990,
|
||||
activitySince: '8 мин назад',
|
||||
alivenessState: 'active',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Фразы для вкладки «Активность» карточки клиента.
|
||||
*
|
||||
* Бэкенд отдаёт событие структурно (что за событие, над чем, на какую сумму),
|
||||
* а человеческую фразу складываем здесь — рядом с форматированием рублей.
|
||||
*/
|
||||
import type { AlivenessState, TenantActivityItem } from './mockTenantDetail';
|
||||
import { formatRub } from './adminTenantDetailFormatters';
|
||||
|
||||
/** Склонение слова «день» по числу. */
|
||||
function daysWord(n: number): string {
|
||||
const mod100 = n % 100;
|
||||
const mod10 = n % 10;
|
||||
if (mod100 >= 11 && mod100 <= 14) return 'дней';
|
||||
if (mod10 === 1) return 'день';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'дня';
|
||||
return 'дней';
|
||||
}
|
||||
|
||||
export function activityPhrase(i: TenantActivityItem): string {
|
||||
const name = i.subject !== null ? `«${i.subject}»` : '';
|
||||
const money = i.amountRub !== null ? formatRub(i.amountRub) : '';
|
||||
|
||||
switch (i.event) {
|
||||
case 'project.created':
|
||||
return `создал проект ${name}`;
|
||||
case 'project.updated':
|
||||
return `изменил проект ${name}`;
|
||||
case 'project.deleted':
|
||||
return `удалил проект ${name}`;
|
||||
case 'project.bulk_pause':
|
||||
return `остановил проекты: ${i.bulkCount}`;
|
||||
case 'project.bulk_resume':
|
||||
return `возобновил проекты: ${i.bulkCount}`;
|
||||
case 'project.bulk_delete':
|
||||
return `удалил проекты: ${i.bulkCount}`;
|
||||
case 'project.bulk_update_limit':
|
||||
return `сменил дневной лимит у проектов: ${i.bulkCount}`;
|
||||
case 'api_key.regenerated':
|
||||
return 'перевыпустил ключ API';
|
||||
case 'webhook_settings.updated':
|
||||
return 'изменил вебхук';
|
||||
case 'webhook_settings.disabled':
|
||||
return 'отключил вебхук';
|
||||
case 'deal.status_changed':
|
||||
return `сменил статус сделки #${i.subject}`;
|
||||
case 'deal.commented':
|
||||
return `написал комментарий к сделке #${i.subject}`;
|
||||
case 'balance.topup':
|
||||
return `пополнил баланс на ${money}`;
|
||||
case 'invoice.issued':
|
||||
return `выставил счёт ${i.subject} на ${money}`;
|
||||
case 'invoice.paid':
|
||||
return `счёт ${i.subject} оплачен, ${money}`;
|
||||
default:
|
||||
return i.event;
|
||||
}
|
||||
}
|
||||
|
||||
const SYSTEMS: Array<[RegExp, string]> = [
|
||||
[/iPhone/i, 'iPhone'],
|
||||
[/iPad/i, 'iPad'],
|
||||
[/Android/i, 'Android'],
|
||||
[/Windows/i, 'Windows'],
|
||||
[/Macintosh|Mac OS X/i, 'Mac'],
|
||||
[/Linux/i, 'Linux'],
|
||||
];
|
||||
|
||||
const BROWSERS: Array<[RegExp, string]> = [
|
||||
[/YaBrowser/i, 'Яндекс'],
|
||||
[/Edg\//i, 'Edge'],
|
||||
[/OPR\//i, 'Opera'],
|
||||
[/Firefox/i, 'Firefox'],
|
||||
[/Chrome/i, 'Chrome'],
|
||||
[/Safari/i, 'Safari'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Порядок проверок важен: Яндекс, Edge и Opera представляются Chrome'ом,
|
||||
* а Chrome — ещё и Safari. Кто раньше в списке, тот и выигрывает.
|
||||
*/
|
||||
export function deviceLabel(userAgent: string | null): string {
|
||||
if (userAgent === null || userAgent.trim() === '') return 'устройство неизвестно';
|
||||
|
||||
const system = SYSTEMS.find(([re]) => re.test(userAgent))?.[1];
|
||||
const browser = BROWSERS.find(([re]) => re.test(userAgent))?.[1];
|
||||
|
||||
if (system === undefined && browser === undefined) return 'устройство неизвестно';
|
||||
if (system === undefined) return browser as string;
|
||||
if (browser === undefined) return system;
|
||||
|
||||
return `${system}/${browser}`;
|
||||
}
|
||||
|
||||
export function alivenessColor(state: AlivenessState): string {
|
||||
return { active: 'success', cooling: 'warning', lost: 'error', never_logged_in: 'error', no_data: 'grey' }[state];
|
||||
}
|
||||
|
||||
/**
|
||||
* Короткая подпись для подсказки в списке клиентов. Там числа суток нет —
|
||||
* полная фраза из alivenessTitle соврала бы «нет данных о входах» любому,
|
||||
* у кого просто не запрошено количество дней.
|
||||
*/
|
||||
export function alivenessShortLabel(state: AlivenessState): string {
|
||||
return {
|
||||
active: 'заходит',
|
||||
cooling: 'остывает',
|
||||
lost: 'пропал',
|
||||
never_logged_in: 'ни разу не заходил',
|
||||
no_data: 'нет данных',
|
||||
}[state];
|
||||
}
|
||||
|
||||
export function alivenessTitle(state: AlivenessState, daysSinceLogin: number | null): string {
|
||||
if (state === 'never_logged_in') return 'Ни разу не заходил после регистрации';
|
||||
if (state === 'no_data' || daysSinceLogin === null) return 'Нет данных о входах';
|
||||
if (daysSinceLogin === 0) return 'Заходил сегодня';
|
||||
if (daysSinceLogin === 1) return 'Заходил вчера';
|
||||
if (state === 'active') return `Заходил ${daysSinceLogin} ${daysWord(daysSinceLogin)} назад`;
|
||||
|
||||
return `Не заходил ${daysSinceLogin} ${daysWord(daysSinceLogin)}`;
|
||||
}
|
||||
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getAdminTenantDetail } from '../../api/admin';
|
||||
import { getAdminTenantActivity, getAdminTenantDetail } from '../../api/admin';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
import { mapAdminTenantDetail } from '../../composables/adminTenantDetailMapper';
|
||||
import { mapAdminTenantDetail, mapTenantActivityItem } from '../../composables/adminTenantDetailMapper';
|
||||
import type { AdminTenantDetail } from '../../composables/mockTenantDetail';
|
||||
import ImpersonationDialog from '../../components/admin/ImpersonationDialog.vue';
|
||||
import TenantDetailHeader from '../../components/admin/tenant-detail/TenantDetailHeader.vue';
|
||||
@@ -77,6 +77,15 @@ async function onBalanceSaved(): Promise<void> {
|
||||
await loadTenant();
|
||||
}
|
||||
|
||||
/** «Показать ещё» на вкладке «Активность» — дописывает следующую страницу дел. */
|
||||
async function loadMoreActivity(): Promise<void> {
|
||||
if (tenant.value === null) return;
|
||||
|
||||
const page = await getAdminTenantActivity(code.value, tenant.value.activity.length);
|
||||
tenant.value.activity = [...tenant.value.activity, ...page.items.map(mapTenantActivityItem)];
|
||||
tenant.value.activityHasMore = page.has_more;
|
||||
}
|
||||
|
||||
defineExpose({ tenant, activeTab, impersonationOpen, balanceDialogOpen, loadTenant });
|
||||
</script>
|
||||
|
||||
@@ -88,7 +97,12 @@ defineExpose({ tenant, activeTab, impersonationOpen, balanceDialogOpen, loadTena
|
||||
@impersonate="impersonationOpen = true"
|
||||
@edit-balance="balanceDialogOpen = true"
|
||||
/>
|
||||
<TenantDetailTabs :tenant="tenant" :active-tab="activeTab" @update:active-tab="activeTab = $event" />
|
||||
<TenantDetailTabs
|
||||
:tenant="tenant"
|
||||
:active-tab="activeTab"
|
||||
@update:active-tab="activeTab = $event"
|
||||
@load-more-activity="loadMoreActivity"
|
||||
/>
|
||||
<ImpersonationDialog v-model="impersonationOpen" :tenant="tenant" :requested-by="ADMIN_USER_ID" />
|
||||
<TenantBalanceDialog
|
||||
v-model="balanceDialogOpen"
|
||||
|
||||
@@ -11,9 +11,17 @@ export function proxyMoney(s: { service_key: string; expires_at?: string | null
|
||||
return s.expires_at ? `до ${s.expires_at}` : '—';
|
||||
}
|
||||
|
||||
/** Слово-статус прокси по светофору. */
|
||||
/**
|
||||
* Слово-статус прокси по светофору.
|
||||
*
|
||||
* 🔴 Серый и красный значат РАЗНОЕ, и путать их нельзя: красный = «прокси кончились», по нему
|
||||
* владелец побежит платить за продление; серый = «не проверяем, ничего не знаем». До 07.08.2026
|
||||
* серый показывался словом «не отвечает» — точка была серой, а подпись врала красным смыслом.
|
||||
* Р126, третья дверь: закрытая дверь теперь красит серым, и такой серый стал обычным делом.
|
||||
*/
|
||||
export function proxyStatus(light: string): string {
|
||||
if (light === 'green') return 'жив';
|
||||
if (light === 'amber') return 'скоро истекает';
|
||||
return 'не отвечает';
|
||||
if (light === 'red') return 'не отвечает';
|
||||
return 'не проверяем'; // grey
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
СТОРОЖА МАШИНЫ РОБОТА: ТРЕВОГА
|
||||
==============================
|
||||
Что случилось: {{ $prichina }}
|
||||
Подробно: {{ $podrobno }}
|
||||
@if($sobranoV)
|
||||
Итог собран: {{ $sobranoV }} (это дата ИЗ САМОГО ИТОГА, а не время получения)
|
||||
@endif
|
||||
@if($vozrastChasov !== null)
|
||||
Возраст итога: {{ $vozrastChasov }} ч
|
||||
@endif
|
||||
Неудач подряд: {{ $skolkoNeudach }}
|
||||
Сейчас: {{ $seychas }} (МСК)
|
||||
|
||||
О ЧЁМ ЭТО ПИСЬМО
|
||||
На машине робота (51.250.1.97) раз в сутки в 05:20 МСК проходит обход
|
||||
сторожей. Сторожа проверяют, что уборка записей телефонных разговоров с
|
||||
живыми людьми вправду работает и что слова этих людей не текут в журналы.
|
||||
Это персональные данные: записи обязаны уходить по сроку, а не копиться.
|
||||
|
||||
ЧТО ДЕЛАТЬ
|
||||
1. Прогнать сторожей рукой с рабочего места и посмотреть, что именно красное:
|
||||
bash bots/lena-golos/proverka-raspisaniya.sh
|
||||
bash bots/lena-golos/proverka-tekstovogo-sleda.sh
|
||||
bash bots/lena-golos/proverka-chistki.sh
|
||||
2. Проверить, жив ли сам обход на машине:
|
||||
bash bots/lena-golos/proverka-sutochnogo-obhoda.sh
|
||||
3. Заглянуть в журнал обхода на машине:
|
||||
/home/ubuntu/lena-golos/sutochnyy-obhod.log
|
||||
|
||||
ЕСЛИ НАПИСАНО «ПРОТУХ»
|
||||
Значит машина перестала стучаться. Внутри последнего итога могло быть всё
|
||||
зелено — это НЕ утешение: зелёный старый итог как раз и усыпляет. Смотреть
|
||||
надо расписание /etc/cron.d/lena-sutochnyy-obhod, сам обход и дорогу до
|
||||
портала, а не содержание последнего итога.
|
||||
|
||||
Это автоматическое сообщение от команды lena:proverka-obhoda (Лидерра).
|
||||
@@ -460,3 +460,16 @@ Schedule::command('obzvon:chistka-materialov')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('obzvon:chistka-materialov', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('obzvon:chistka-materialov', false, 'Command failed', null));
|
||||
|
||||
// З-0.10 (обзвон): проверка суточного обхода сторожей на машине робота.
|
||||
// 🔴 У этой команды зовущий ОДИН — эти строки. Не будь их, машина стучалась бы
|
||||
// в пустоту: итог ложился бы в таблицу отметок, и никто бы на него не смотрел.
|
||||
// Именно так и выглядит молчун — всё построено и всё мертво.
|
||||
// 06:10 МСК — обход на машине ходит в 05:20 МСК, 50 минут запаса на долгий
|
||||
// обход и на сдвиг расписания. Раньше звать нельзя: увидим вчерашний итог и
|
||||
// заругаемся зря.
|
||||
Schedule::command('lena:proverka-obhoda')
|
||||
->dailyAt('06:10')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('lena:proverka-obhoda', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('lena:proverka-obhoda', false, 'Command failed', null));
|
||||
|
||||
@@ -177,6 +177,9 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () {
|
||||
Route::get('/api/admin/tenants', 'App\Http\Controllers\Api\AdminTenantsController@index');
|
||||
Route::get('/api/admin/tenants/{subdomain}', 'App\Http\Controllers\Api\AdminTenantsController@show')
|
||||
->where('subdomain', '[a-z0-9_-]+');
|
||||
// Подгрузка следующей страницы ленты дел на вкладке «Активность» («показать ещё»).
|
||||
Route::get('/api/admin/tenants/{subdomain}/activity', 'App\Http\Controllers\Api\AdminTenantsController@activity')
|
||||
->where('subdomain', '[a-z0-9_-]+');
|
||||
Route::patch('/api/admin/tenants/{id}/balance', 'App\Http\Controllers\Api\AdminTenantsController@updateBalance')
|
||||
->where('id', '[0-9]+');
|
||||
|
||||
@@ -728,6 +731,13 @@ Route::post('/api/webhook/mts-delivery/{secret}', 'App\Http\Controllers\Api\MtsD
|
||||
Route::match(['get', 'post'], '/api/webhook/t2-delivery/{secret}', 'App\Http\Controllers\Api\T2DeliveryWebhookController@receive')
|
||||
->where('secret', '[A-Za-z0-9_\-]+');
|
||||
|
||||
// Суточный итог сторожей с машины робота (З-0.10, решение владельца Р116).
|
||||
// Стучится МАШИНА, портал на машину не ходит: ключа от машины, где лежат
|
||||
// голоса живых людей, у боевого портала быть не должно.
|
||||
// 🔴 Секрета в АДРЕСЕ здесь НЕТ намеренно — только подпись тела
|
||||
// (X-Storozh-Podpis). Адреса целиком текут в журналы nginx, тело — нет.
|
||||
Route::post('/api/webhook/lena-storozha', 'App\Http\Controllers\Api\LenaStorozhaWebhookController@receive');
|
||||
|
||||
// Платёжный webhook (ЮKassa). Публичный, под маской api/webhook/* → CSRF-exempt.
|
||||
// Подлинность — server-to-server сверкой статуса (не доверяем телу). Plan billing-yookassa Task 7.
|
||||
Route::post('/api/webhook/payment', 'App\Http\Controllers\Api\PaymentWebhookController@receive');
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Admin\TenantActivityFeedService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
function feedTenant(): array
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id, 'email' => 'client@example.test']);
|
||||
|
||||
return [(int) $tenant->id, (int) $user->id];
|
||||
}
|
||||
|
||||
function feedOps(int $tenantId, ?int $userId, string $event, ?int $entityId, ?array $after, ?array $before, string $mskTime): void
|
||||
{
|
||||
DB::table('tenant_operations_log')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'entity_type' => 'project',
|
||||
'entity_id' => $entityId,
|
||||
'event' => $event,
|
||||
'payload_before' => $before !== null ? json_encode($before, JSON_UNESCAPED_UNICODE) : null,
|
||||
'payload_after' => $after !== null ? json_encode($after, JSON_UNESCAPED_UNICODE) : null,
|
||||
'created_at' => CarbonImmutable::parse($mskTime, 'Europe/Moscow')->utc(),
|
||||
]);
|
||||
}
|
||||
|
||||
function feedActivity(int $tenantId, ?int $userId, string $event, int $dealId, string $mskTime): void
|
||||
{
|
||||
DB::table('activity_log')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'deal_id' => $dealId,
|
||||
'event' => $event,
|
||||
'created_at' => CarbonImmutable::parse($mskTime, 'Europe/Moscow')->utc(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('берёт имя проекта из журнала, а не номер', function () {
|
||||
[$tenantId, $userId] = feedTenant();
|
||||
feedOps($tenantId, $userId, 'project.created', 999_001, ['name' => 'Двери'], null, '2026-08-05 10:10:00');
|
||||
|
||||
$items = app(TenantActivityFeedService::class)->forTenant($tenantId, 50, 0);
|
||||
|
||||
expect($items)->toHaveCount(1);
|
||||
expect($items[0]['kind'])->toBe('project');
|
||||
expect($items[0]['event'])->toBe('project.created');
|
||||
expect($items[0]['subject'])->toBe('Двери');
|
||||
expect($items[0]['actor'])->toBe('client@example.test');
|
||||
});
|
||||
|
||||
it('берёт имя удалённого проекта из снимка «до»', function () {
|
||||
[$tenantId, $userId] = feedTenant();
|
||||
feedOps($tenantId, $userId, 'project.deleted', 999_002, null, ['name' => 'Окна ЕКБ'], '2026-08-05 11:00:00');
|
||||
|
||||
$items = app(TenantActivityFeedService::class)->forTenant($tenantId, 50, 0);
|
||||
|
||||
expect($items[0]['subject'])->toBe('Окна ЕКБ');
|
||||
});
|
||||
|
||||
it('считает, сколько проектов задето групповым действием', function () {
|
||||
[$tenantId, $userId] = feedTenant();
|
||||
feedOps($tenantId, $userId, 'project.bulk_pause', null, ['ids' => [1, 2, 3]], null, '2026-08-05 12:00:00');
|
||||
|
||||
$items = app(TenantActivityFeedService::class)->forTenant($tenantId, 50, 0);
|
||||
|
||||
expect($items[0]['bulk_count'])->toBe(3);
|
||||
});
|
||||
|
||||
it('пускает ручную работу со сделками и НЕ пускает системную', function () {
|
||||
[$tenantId, $userId] = feedTenant();
|
||||
feedActivity($tenantId, $userId, 'deal.status_changed', 555, '2026-08-05 13:00:00');
|
||||
feedActivity($tenantId, null, 'deal.created', 556, '2026-08-05 13:05:00');
|
||||
|
||||
$items = app(TenantActivityFeedService::class)->forTenant($tenantId, 50, 0);
|
||||
|
||||
expect($items)->toHaveCount(1);
|
||||
expect($items[0]['event'])->toBe('deal.status_changed');
|
||||
expect($items[0]['subject'])->toBe('555');
|
||||
});
|
||||
|
||||
it('пускает пополнение и НЕ пускает списания за лид и правку баланса админом', function () {
|
||||
[$tenantId, $userId] = feedTenant();
|
||||
|
||||
DB::table('balance_transactions')->insert([
|
||||
['tenant_id' => $tenantId, 'user_id' => $userId, 'type' => 'topup',
|
||||
'amount_rub' => '1000.00', 'created_at' => CarbonImmutable::parse('2026-08-05 14:00:00', 'Europe/Moscow')->utc()],
|
||||
['tenant_id' => $tenantId, 'user_id' => null, 'type' => 'lead_charge',
|
||||
'amount_rub' => '-20.00', 'created_at' => CarbonImmutable::parse('2026-08-05 14:05:00', 'Europe/Moscow')->utc()],
|
||||
['tenant_id' => $tenantId, 'user_id' => null, 'type' => 'manual_adjustment',
|
||||
'amount_rub' => '500.00', 'created_at' => CarbonImmutable::parse('2026-08-05 14:10:00', 'Europe/Moscow')->utc()],
|
||||
]);
|
||||
|
||||
$items = app(TenantActivityFeedService::class)->forTenant($tenantId, 50, 0);
|
||||
|
||||
expect($items)->toHaveCount(1);
|
||||
expect($items[0]['kind'])->toBe('money');
|
||||
expect($items[0]['event'])->toBe('balance.topup');
|
||||
expect($items[0]['amount_rub'])->toBe('1000.00');
|
||||
});
|
||||
|
||||
it('отдаёт свежее первым и умеет постранично', function () {
|
||||
[$tenantId, $userId] = feedTenant();
|
||||
feedOps($tenantId, $userId, 'project.created', 999_010, ['name' => 'Первый'], null, '2026-08-01 10:00:00');
|
||||
feedOps($tenantId, $userId, 'project.created', 999_011, ['name' => 'Второй'], null, '2026-08-02 10:00:00');
|
||||
feedOps($tenantId, $userId, 'project.created', 999_012, ['name' => 'Третий'], null, '2026-08-03 10:00:00');
|
||||
|
||||
$service = app(TenantActivityFeedService::class);
|
||||
|
||||
$first = $service->forTenant($tenantId, 2, 0);
|
||||
expect(array_column($first, 'subject'))->toBe(['Третий', 'Второй']);
|
||||
|
||||
$second = $service->forTenant($tenantId, 2, 2);
|
||||
expect(array_column($second, 'subject'))->toBe(['Первый']);
|
||||
});
|
||||
|
||||
it('считает дела за период тем же отбором, что и лента', function () {
|
||||
[$tenantId, $userId] = feedTenant();
|
||||
feedOps($tenantId, $userId, 'project.created', 999_020, ['name' => 'Свежий'], null, '2026-08-05 10:00:00');
|
||||
feedOps($tenantId, $userId, 'project.created', 999_021, ['name' => 'Старый'], null, '2026-05-01 10:00:00');
|
||||
feedActivity($tenantId, null, 'deal.created', 777, '2026-08-05 10:30:00');
|
||||
|
||||
$since = CarbonImmutable::parse('2026-07-09 00:00:00', 'Europe/Moscow')->utc();
|
||||
|
||||
expect(app(TenantActivityFeedService::class)->countSince($tenantId, $since))->toBe(1);
|
||||
});
|
||||
|
||||
it('карточка клиента отдаёт блок «жив ли» и ленту без системного шума', function () {
|
||||
$tenant = Tenant::factory()->create(['subdomain' => 'testalive']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id, 'email' => 'client@example.test']);
|
||||
|
||||
feedOps((int) $tenant->id, (int) $user->id, 'project.created', 999_100, ['name' => 'Двери'], null, '2026-08-05 10:10:00');
|
||||
feedActivity((int) $tenant->id, null, 'deal.created', 888, '2026-08-05 11:00:00');
|
||||
|
||||
$resp = $this->getJson('/api/admin/tenants/testalive');
|
||||
|
||||
$resp->assertOk()
|
||||
->assertJsonPath('aliveness.state', 'no_data')
|
||||
->assertJsonCount(1, 'activity.items')
|
||||
->assertJsonPath('activity.items.0.event', 'project.created')
|
||||
->assertJsonPath('activity.items.0.subject', 'Двери')
|
||||
->assertJsonPath('activity.has_more', false);
|
||||
});
|
||||
|
||||
it('отдаёт следующую страницу ленты отдельной ручкой', function () {
|
||||
$tenant = Tenant::factory()->create(['subdomain' => 'testpage']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
feedOps((int) $tenant->id, (int) $user->id, 'project.created', 999_200, ['name' => 'Первый'], null, '2026-08-01 10:00:00');
|
||||
feedOps((int) $tenant->id, (int) $user->id, 'project.created', 999_201, ['name' => 'Второй'], null, '2026-08-02 10:00:00');
|
||||
|
||||
$resp = $this->getJson('/api/admin/tenants/testpage/activity?offset=1&limit=1');
|
||||
|
||||
$resp->assertOk()
|
||||
->assertJsonCount(1, 'items')
|
||||
->assertJsonPath('items.0.subject', 'Первый')
|
||||
->assertJsonPath('has_more', false);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Admin\TenantAlivenessService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/** Кладёт строку в журнал входов на указанное московское время. */
|
||||
function alivenessLog(int $tenantId, ?int $userId, string $event, string $mskTime): void
|
||||
{
|
||||
DB::table('auth_log')->insert([
|
||||
'actor_type' => 'tenant_user',
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'email' => 'client@example.test',
|
||||
'event' => $event,
|
||||
'ip_address' => '5.166.40.65',
|
||||
'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||
'created_at' => CarbonImmutable::parse($mskTime, 'Europe/Moscow')->utc(),
|
||||
]);
|
||||
}
|
||||
|
||||
function alivenessTenant(): array
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
return [(int) $tenant->id, (int) $user->id];
|
||||
}
|
||||
|
||||
it('считает клиента живым, если он заходил не позже трёх суток назад', function () {
|
||||
[$tenantId, $userId] = alivenessTenant();
|
||||
$now = CarbonImmutable::parse('2026-08-07 12:00:00', 'Europe/Moscow');
|
||||
|
||||
alivenessLog($tenantId, $userId, 'login_success', '2026-08-04 09:00:00');
|
||||
|
||||
$result = app(TenantAlivenessService::class)->forTenant($tenantId, $now);
|
||||
|
||||
expect($result['state'])->toBe('active');
|
||||
expect($result['days_since_login'])->toBe(3);
|
||||
});
|
||||
|
||||
it('считает клиента остывающим на четвёртые сутки', function () {
|
||||
[$tenantId, $userId] = alivenessTenant();
|
||||
$now = CarbonImmutable::parse('2026-08-07 12:00:00', 'Europe/Moscow');
|
||||
|
||||
alivenessLog($tenantId, $userId, 'login_success', '2026-08-03 09:00:00');
|
||||
|
||||
$result = app(TenantAlivenessService::class)->forTenant($tenantId, $now);
|
||||
|
||||
expect($result['state'])->toBe('cooling');
|
||||
expect($result['days_since_login'])->toBe(4);
|
||||
});
|
||||
|
||||
it('считает клиента пропавшим на четырнадцатые сутки', function () {
|
||||
[$tenantId, $userId] = alivenessTenant();
|
||||
$now = CarbonImmutable::parse('2026-08-07 12:00:00', 'Europe/Moscow');
|
||||
|
||||
alivenessLog($tenantId, $userId, 'login_success', '2026-07-24 09:00:00');
|
||||
|
||||
$result = app(TenantAlivenessService::class)->forTenant($tenantId, $now);
|
||||
|
||||
expect($result['state'])->toBe('lost');
|
||||
expect($result['days_since_login'])->toBe(14);
|
||||
});
|
||||
|
||||
it('не путает сутки в первые три часа ночи по Москве', function () {
|
||||
[$tenantId, $userId] = alivenessTenant();
|
||||
// 01:30 МСК седьмого августа — это ещё 22:30 UTC шестого.
|
||||
// Наивная граница дала бы «вчера» вместо «сегодня».
|
||||
$now = CarbonImmutable::parse('2026-08-07 01:30:00', 'Europe/Moscow');
|
||||
|
||||
alivenessLog($tenantId, $userId, 'login_success', '2026-08-07 01:00:00');
|
||||
|
||||
$result = app(TenantAlivenessService::class)->forTenant($tenantId, $now);
|
||||
|
||||
expect($result['days_since_login'])->toBe(0);
|
||||
expect($result['state'])->toBe('active');
|
||||
});
|
||||
|
||||
it('отличает зарегистрировавшегося без единого входа', function () {
|
||||
[$tenantId, $userId] = alivenessTenant();
|
||||
$now = CarbonImmutable::parse('2026-08-07 12:00:00', 'Europe/Moscow');
|
||||
|
||||
alivenessLog($tenantId, $userId, 'register_success', '2026-08-06 09:00:00');
|
||||
|
||||
$result = app(TenantAlivenessService::class)->forTenant($tenantId, $now);
|
||||
|
||||
expect($result['state'])->toBe('never_logged_in');
|
||||
});
|
||||
|
||||
it('отдаёт no_data, когда в журнале нет ни одной строки', function () {
|
||||
[$tenantId] = alivenessTenant();
|
||||
$now = CarbonImmutable::parse('2026-08-07 12:00:00', 'Europe/Moscow');
|
||||
|
||||
$result = app(TenantAlivenessService::class)->forTenant($tenantId, $now);
|
||||
|
||||
expect($result['state'])->toBe('no_data');
|
||||
expect($result['last_login_at'])->toBeNull();
|
||||
expect($result['days'])->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
function listTenantWithLogin(string $subdomain, ?string $mskTime): Tenant
|
||||
{
|
||||
$tenant = Tenant::factory()->create(['subdomain' => $subdomain]);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
if ($mskTime !== null) {
|
||||
DB::table('auth_log')->insert([
|
||||
'actor_type' => 'tenant_user',
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'email' => 'x@example.test',
|
||||
'event' => 'login_success',
|
||||
'created_at' => CarbonImmutable::parse($mskTime, 'Europe/Moscow')->utc(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
it('сортирует список по последнему входу из журнала, а не по мёртвой колонке', function () {
|
||||
// Мёртвая колонка нарочно заполнена наоборот: если сортировка всё ещё
|
||||
// смотрит на неё, порядок получится обратный и тест покраснеет.
|
||||
$stale = listTenantWithLogin('staleone', CarbonImmutable::now('Europe/Moscow')->subDays(10)->toDateTimeString());
|
||||
$fresh = listTenantWithLogin('freshone', CarbonImmutable::now('Europe/Moscow')->subHours(2)->toDateTimeString());
|
||||
|
||||
DB::table('tenants')->where('id', $stale->id)->update(['last_activity_at' => now()]);
|
||||
DB::table('tenants')->where('id', $fresh->id)->update(['last_activity_at' => now()->subYear()]);
|
||||
|
||||
$resp = $this->getJson('/api/admin/tenants?limit=100');
|
||||
|
||||
$resp->assertOk();
|
||||
$subdomains = array_column($resp->json('tenants'), 'subdomain');
|
||||
$freshPos = array_search('freshone', $subdomains, true);
|
||||
$stalePos = array_search('staleone', $subdomains, true);
|
||||
|
||||
expect($freshPos)->toBeLessThan($stalePos);
|
||||
});
|
||||
|
||||
it('отдаёт состояние клиента по порогам три и четырнадцать', function () {
|
||||
listTenantWithLogin('aliveone', CarbonImmutable::now('Europe/Moscow')->subDays(1)->toDateTimeString());
|
||||
listTenantWithLogin('coolone', CarbonImmutable::now('Europe/Moscow')->subDays(6)->toDateTimeString());
|
||||
listTenantWithLogin('lostone', CarbonImmutable::now('Europe/Moscow')->subDays(20)->toDateTimeString());
|
||||
listTenantWithLogin('noneone', null);
|
||||
|
||||
$rows = collect($this->getJson('/api/admin/tenants?limit=100')->json('tenants'))
|
||||
->keyBy('subdomain');
|
||||
|
||||
expect($rows['aliveone']['aliveness_state'])->toBe('active');
|
||||
expect($rows['coolone']['aliveness_state'])->toBe('cooling');
|
||||
expect($rows['lostone']['aliveness_state'])->toBe('lost');
|
||||
expect($rows['noneone']['aliveness_state'])->toBe('lost');
|
||||
expect($rows['noneone']['last_activity_at'])->toBeNull();
|
||||
});
|
||||
|
||||
it('не делает запрос на каждого клиента', function () {
|
||||
foreach (range(1, 12) as $i) {
|
||||
listTenantWithLogin('many'.$i, CarbonImmutable::now('Europe/Moscow')->subDays($i)->toDateTimeString());
|
||||
}
|
||||
|
||||
DB::enableQueryLog();
|
||||
$this->getJson('/api/admin/tenants?limit=100')->assertOk();
|
||||
$queries = count(DB::getQueryLog());
|
||||
DB::disableQueryLog();
|
||||
|
||||
// Список, счёт всего и блок сводки — единицы запросов, а не «по одному
|
||||
// на клиента». Вопрос внутри перебора ловился в проекте дважды за неделю.
|
||||
expect($queries)->toBeLessThan(15);
|
||||
});
|
||||
@@ -159,7 +159,11 @@ test('GET show: balance_history изоляция (чужие tx не показ
|
||||
expect($descriptions)->not->toContain('foreign');
|
||||
});
|
||||
|
||||
test('GET show: activity возвращает с actor_email из users LEFT JOIN', function () {
|
||||
// Форма ответа сменилась 07.08.2026: вкладка «Активность» отвечает на вопрос
|
||||
// «жив ли клиент», и системные строки в ленту больше не идут — они и забивали
|
||||
// экран (364 из 419 строк на боевом). Раньше здесь ожидались обе строки
|
||||
// плоским массивом с actor_email; теперь — страница {items,...} только с делами.
|
||||
test('GET show: лента отдаёт человеческое дело с автором и молчит о системном', function () {
|
||||
$user = User::factory()->create(['tenant_id' => $this->tenant->id, 'email' => 'actor@test.io']);
|
||||
DB::table('activity_log')->insert([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
@@ -178,15 +182,15 @@ test('GET show: activity возвращает с actor_email из users LEFT JOI
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/admin/tenants/{$this->tenant->subdomain}");
|
||||
$activity = $response->json('activity');
|
||||
expect($activity)->toHaveCount(2);
|
||||
$events = array_column($activity, 'event');
|
||||
expect($events)->toContain('deal.status_changed');
|
||||
expect($events)->toContain('webhook.received');
|
||||
// Самая свежая первая.
|
||||
expect($activity[0]['event'])->toBe('deal.status_changed');
|
||||
expect($activity[0]['actor_email'])->toBe('actor@test.io');
|
||||
expect($activity[1]['actor_email'])->toBeNull();
|
||||
$items = $response->json('activity.items');
|
||||
|
||||
expect($items)->toHaveCount(1);
|
||||
expect($items[0]['event'])->toBe('deal.status_changed');
|
||||
expect($items[0]['kind'])->toBe('deal');
|
||||
expect($items[0]['subject'])->toBe('999');
|
||||
expect($items[0]['actor'])->toBe('actor@test.io');
|
||||
expect(array_column($items, 'event'))->not->toContain('webhook.received');
|
||||
expect($response->json('activity.has_more'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('GET show: metrics.leads_today + this_week + this_month', function () {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -72,19 +73,25 @@ test('GET /api/admin/tenants фильтрует по search (organization_name +
|
||||
expect($this->getJson('/api/admin/tenants?search=что-то-несуществующее')->json('total'))->toBe(0);
|
||||
});
|
||||
|
||||
test('GET /api/admin/tenants сортирует по last_activity_at DESC', function () {
|
||||
$oldest = Tenant::factory()->create([
|
||||
'organization_name' => 'oldest',
|
||||
'last_activity_at' => now()->subDays(5),
|
||||
]);
|
||||
$newest = Tenant::factory()->create([
|
||||
'organization_name' => 'newest',
|
||||
'last_activity_at' => now()->subMinutes(5),
|
||||
]);
|
||||
$middle = Tenant::factory()->create([
|
||||
'organization_name' => 'middle',
|
||||
'last_activity_at' => now()->subDays(1),
|
||||
]);
|
||||
// 07.08.2026: сортировка переехала с колонки tenants.last_activity_at на живой
|
||||
// журнал входов. Колонку никто не заполняет (ноль из одиннадцати на боевом),
|
||||
// поэтому этот тест раньше проверял порядок, которого в жизни не бывало.
|
||||
test('GET /api/admin/tenants сортирует по последнему входу из журнала DESC', function () {
|
||||
$seedLogin = function (string $name, string $ago): void {
|
||||
$tenant = Tenant::factory()->create(['organization_name' => $name]);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
DB::table('auth_log')->insert([
|
||||
'actor_type' => 'tenant_user',
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'event' => 'login_success',
|
||||
'created_at' => now()->sub($ago),
|
||||
]);
|
||||
};
|
||||
|
||||
$seedLogin('oldest', '5 days');
|
||||
$seedLogin('newest', '5 minutes');
|
||||
$seedLogin('middle', '1 day');
|
||||
|
||||
$r = $this->getJson('/api/admin/tenants');
|
||||
$names = collect($r->json('tenants'))->pluck('organization_name')->all();
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ClientSmsSettings;
|
||||
use App\Services\ClientSms\LocalHoursWindow;
|
||||
use App\Services\ClientSms\SmsQuietHours;
|
||||
use App\Support\RegionTimezoneMap;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Второе окно — часы менеджера клиента (задача З-3.1, решения владельца Р64, Р76, Р115).
|
||||
*
|
||||
* 🔴 Здесь сторожат САМО ПРАВИЛО. Проверки обёртки обзвона — в
|
||||
* `tests/Feature/Obzvon/ObzvonOknoChasovTest.php`.
|
||||
*
|
||||
* Почему это надо сторожить отдельно и заранее: ошибка на час здесь не видна ни в
|
||||
* одном прогоне — она видна только менеджеру, которого робот разбудил переводом
|
||||
* звонка в четыре утра, и разбуженному человеку на том конце.
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/** Момент UTC по человеческой записи — чтобы в тесте было видно, который час. */
|
||||
function vtoroeOknoUtc(string $iso): CarbonImmutable
|
||||
{
|
||||
return CarbonImmutable::parse($iso, 'UTC');
|
||||
}
|
||||
|
||||
$MSK = 3 * 60;
|
||||
$VLADIVOSTOK = 10 * 60;
|
||||
$KAMCHATKA = 12 * 60;
|
||||
|
||||
it('🔴 ЛОВУШКА: часы менеджера меряются ЕГО поясом, а не поясом получателя', function () use ($MSK, $VLADIVOSTOK) {
|
||||
// Менеджер в Москве работает 10:00–18:00 — это 07:00–15:00 UTC.
|
||||
// Получатель во Владивостоке: его рамка 10:00–20:00 — это 00:00–10:00 UTC.
|
||||
// Общее окно у них — 07:00–10:00 UTC (10–13 МСК = 17–20 по Владивостоку).
|
||||
$rule = app(SmsQuietHours::class);
|
||||
$menedzher = new LocalHoursWindow($MSK, 10, 18);
|
||||
|
||||
// 07:30 UTC = 10:30 МСК (менеджер на месте) = 17:30 Владивосток (рамка открыта).
|
||||
expect($rule->canSendNow($VLADIVOSTOK, vtoroeOknoUtc('2026-08-03 07:30'), $menedzher))->toBeTrue();
|
||||
|
||||
// 🔴 Вот на чём краснеет «просто сузили окно». 04:30 UTC = 14:30 ВЛАДИВОСТОКА:
|
||||
// сузив окно получателя часами менеджера, портал прочёл бы «10:00–18:00» как
|
||||
// владивостокские и набрал бы номер. А в Москве в этот миг 07:30 — менеджер спит.
|
||||
expect($rule->canSendNow($VLADIVOSTOK, vtoroeOknoUtc('2026-08-03 04:30'), $menedzher))->toBeFalse();
|
||||
|
||||
// И наоборот: 10:30 UTC = 13:30 МСК (менеджер на месте), но во Владивостоке
|
||||
// уже 20:30 — рамка получателя закрыта. Набора нет.
|
||||
expect($rule->canSendNow($VLADIVOSTOK, vtoroeOknoUtc('2026-08-03 10:30'), $menedzher))->toBeFalse();
|
||||
});
|
||||
|
||||
it('пустое второе окно не меняет сегодняшнего ответа ни на один час', function () use ($MSK) {
|
||||
$rule = app(SmsQuietHours::class);
|
||||
|
||||
for ($chas = 0; $chas < 24; $chas++) {
|
||||
$mig = vtoroeOknoUtc('2026-08-03 00:00')->addHours($chas);
|
||||
|
||||
expect($rule->canSendNow($MSK, $mig, null))
|
||||
->toBe($rule->canSendNow($MSK, $mig), "час UTC {$chas}");
|
||||
}
|
||||
});
|
||||
|
||||
it('окно клиента только СУЖАЕТ рамку и никогда не расширяет', function () use ($MSK) {
|
||||
$rule = app(SmsQuietHours::class);
|
||||
|
||||
// Внутри рамки — остаётся собой.
|
||||
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 11, 17))->podpis())->toBe('11:00–17:00');
|
||||
|
||||
// Шире рамки — принимается как рамка (Р64).
|
||||
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 9, 22))->podpis())->toBe('10:00–20:00');
|
||||
|
||||
// Одним краем наружу — подрезается этот край.
|
||||
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 8, 15))->podpis())->toBe('10:00–15:00');
|
||||
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 15, 23))->podpis())->toBe('15:00–20:00');
|
||||
});
|
||||
|
||||
it('окно 09:00–22:00 не даёт набрать ни в 09 часов, ни в 21', function () use ($MSK) {
|
||||
$rule = app(SmsQuietHours::class);
|
||||
$menedzher = new LocalHoursWindow($MSK, 9, 22);
|
||||
|
||||
// Получатель тоже в Москве — тогда весь спор только про сужение.
|
||||
expect($rule->canSendNow($MSK, vtoroeOknoUtc('2026-08-03 06:30'), $menedzher))->toBeFalse(); // 09:30 МСК
|
||||
expect($rule->canSendNow($MSK, vtoroeOknoUtc('2026-08-03 07:30'), $menedzher))->toBeTrue(); // 10:30 МСК
|
||||
expect($rule->canSendNow($MSK, vtoroeOknoUtc('2026-08-03 16:30'), $menedzher))->toBeTrue(); // 19:30 МСК
|
||||
expect($rule->canSendNow($MSK, vtoroeOknoUtc('2026-08-03 17:30'), $menedzher))->toBeFalse(); // 20:30 МСК
|
||||
});
|
||||
|
||||
it('окно целиком вне рамки говорит «звонков не будет», а не молчит', function () use ($MSK) {
|
||||
$rule = app(SmsQuietHours::class);
|
||||
$menedzher = new LocalHoursWindow($MSK, 20, 23);
|
||||
|
||||
// Сужение пусто — экрану есть что сказать человеку.
|
||||
expect($rule->narrowedWindow($menedzher))->toBeNull();
|
||||
|
||||
// И набора нет ни в один час суток.
|
||||
for ($chas = 0; $chas < 24; $chas++) {
|
||||
expect($rule->canSendNow($MSK, vtoroeOknoUtc('2026-08-03 00:00')->addHours($chas), $menedzher))
|
||||
->toBeFalse("час UTC {$chas}");
|
||||
}
|
||||
|
||||
// «Когда откроется» отвечает пустотой — это ответ «никогда», а не «прямо сейчас».
|
||||
expect($rule->nextCommonWindowOpensAt($MSK, $menedzher, vtoroeOknoUtc('2026-08-03 09:00')))->toBeNull();
|
||||
});
|
||||
|
||||
it('🔴 общее окно может не существовать вовсе из-за поясов, и это сказано вслух', function () use ($MSK, $KAMCHATKA, $VLADIVOSTOK) {
|
||||
// Менеджер в Москве 14:00–18:00 = 11:00–15:00 UTC — часы сами по себе годные,
|
||||
// они целиком внутри рамки, сужать нечего.
|
||||
// Камчатка: рамка 10:00–20:00 = 22:00–08:00 UTC. Общего часа НЕТ ни одного.
|
||||
// Владивосток: рамка 10:00–20:00 = 00:00–10:00 UTC. Тоже ни одного.
|
||||
$rule = app(SmsQuietHours::class);
|
||||
$menedzher = new LocalHoursWindow($MSK, 14, 18);
|
||||
|
||||
foreach ([$KAMCHATKA, $VLADIVOSTOK] as $poyas) {
|
||||
for ($chas = 0; $chas < 24; $chas++) {
|
||||
expect($rule->canSendNow($poyas, vtoroeOknoUtc('2026-08-03 00:00')->addHours($chas), $menedzher))
|
||||
->toBeFalse("пояс {$poyas}, час UTC {$chas}");
|
||||
}
|
||||
|
||||
// 🔴 Пустота здесь означает «никогда», и молчать об этом нельзя.
|
||||
expect($rule->nextCommonWindowOpensAt($poyas, $menedzher, vtoroeOknoUtc('2026-08-03 09:00')))->toBeNull();
|
||||
}
|
||||
|
||||
// А сужение при этом НЕ пусто: часы клиента сами по себе годные — беда в поясах.
|
||||
// Две причины «звонков не будет» портал различает, потому что говорит о них разное.
|
||||
expect($rule->narrowedWindow($menedzher))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('говорит, когда откроется ОБЩЕЕ окно, и не промахивается мимо него', function () use ($MSK, $VLADIVOSTOK) {
|
||||
$rule = app(SmsQuietHours::class);
|
||||
$menedzher = new LocalHoursWindow($MSK, 10, 18);
|
||||
|
||||
// Общее окно у Москвы-менеджера и Владивостока-получателя: 07:00–10:00 UTC.
|
||||
// Спрашиваем в 00:00 UTC — ждём до 07:00 UTC.
|
||||
expect($rule->nextCommonWindowOpensAt($VLADIVOSTOK, $menedzher, vtoroeOknoUtc('2026-08-03 00:00'))?->toIso8601String())
|
||||
->toBe(vtoroeOknoUtc('2026-08-03 07:00')->toIso8601String());
|
||||
|
||||
// Открыто прямо сейчас — отдаёт этот же миг, а не завтрашний.
|
||||
expect($rule->nextCommonWindowOpensAt($VLADIVOSTOK, $menedzher, vtoroeOknoUtc('2026-08-03 08:00'))?->toIso8601String())
|
||||
->toBe(vtoroeOknoUtc('2026-08-03 08:00')->toIso8601String());
|
||||
|
||||
// Окно уже закрылось — ждём ЗАВТРАШНЕГО, а не сегодняшнего.
|
||||
expect($rule->nextCommonWindowOpensAt($VLADIVOSTOK, $menedzher, vtoroeOknoUtc('2026-08-03 12:00'))?->toIso8601String())
|
||||
->toBe(vtoroeOknoUtc('2026-08-04 07:00')->toIso8601String());
|
||||
});
|
||||
|
||||
it('🔴 обещанный миг открытия действительно открыт, а до него закрыто — на всех поясах', function () use ($MSK) {
|
||||
// Сторож на промах «когда откроется»: обещание сверяется с самим правилом.
|
||||
// Ошибка на час тут иначе не видна — она видна разбуженному человеку.
|
||||
$rule = app(SmsQuietHours::class);
|
||||
$menedzher = new LocalHoursWindow($MSK, 11, 17);
|
||||
|
||||
$poyasa = array_values(array_unique(array_map(
|
||||
static fn (int $ch): int => $ch * 60,
|
||||
RegionTimezoneMap::CODE_TO_UTC_HOURS
|
||||
)));
|
||||
|
||||
$proverok = 0;
|
||||
|
||||
foreach ($poyasa as $poyas) {
|
||||
for ($chas = 0; $chas < 24; $chas++) {
|
||||
$seychas = vtoroeOknoUtc('2026-08-03 00:00')->addHours($chas);
|
||||
$otkroetsya = $rule->nextCommonWindowOpensAt($poyas, $menedzher, $seychas);
|
||||
$proverok++;
|
||||
|
||||
if ($otkroetsya === null) {
|
||||
// «Никогда» обязано означать, что и правда никогда — ни в одну минуту суток.
|
||||
for ($m = 0; $m < 1440; $m += 7) {
|
||||
expect($rule->canSendNow($poyas, $seychas->addMinutes($m), $menedzher))
|
||||
->toBeFalse("пояс {$poyas}: обещано «никогда», а в +{$m} мин открыто");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
expect($rule->canSendNow($poyas, $otkroetsya, $menedzher))
|
||||
->toBeTrue("пояс {$poyas}, спросили в {$seychas->toIso8601String()}: обещан {$otkroetsya->toIso8601String()}, а там закрыто");
|
||||
|
||||
// И ни одной минуты между «сейчас» и обещанным мигом открытым быть не должно.
|
||||
$minut = (int) $seychas->diffInMinutes($otkroetsya);
|
||||
|
||||
for ($m = 0; $m < $minut; $m++) {
|
||||
if ($rule->canSendNow($poyas, $seychas->addMinutes($m), $menedzher)) {
|
||||
throw new RuntimeException(
|
||||
"пояс {$poyas}: открыто уже в +{$m} мин, а обещано только через {$minut} мин"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect($proverok)->toBe(count($poyasa) * 24);
|
||||
});
|
||||
|
||||
it('бессмысленное окно менеджера отвергается тем же правилом, что и окно рассылки', function () use ($MSK) {
|
||||
// «С 20 до 10» означало бы работу всю ночь. Второй проверки на это в портале нет.
|
||||
expect(fn () => new LocalHoursWindow($MSK, 20, 10))->toThrow(InvalidArgumentException::class);
|
||||
expect(fn () => new LocalHoursWindow($MSK, 12, 12))->toThrow(InvalidArgumentException::class);
|
||||
expect(fn () => new LocalHoursWindow($MSK, -1, 10))->toThrow(InvalidArgumentException::class);
|
||||
expect(fn () => new LocalHoursWindow($MSK, 10, 25))->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('второе окно слушается границ, выставленных владельцем в админке', function () use ($MSK) {
|
||||
// Рамку двигает админ — и сужение обязано двигаться вместе с ней.
|
||||
ClientSmsSettings::query()->update(['quiet_from_hour' => 12, 'quiet_to_hour' => 18]);
|
||||
|
||||
$rule = app(SmsQuietHours::class);
|
||||
|
||||
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 9, 22))->podpis())->toBe('12:00–18:00');
|
||||
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 10, 20))->podpis())->toBe('12:00–18:00');
|
||||
});
|
||||
|
||||
it('правило со своими границами не ходит в базу ни разу', function () {
|
||||
// Границы названы снаружи — значит на 20 000 номеров не будет ни одного запроса.
|
||||
$rule = SmsQuietHours::withWindow(10, 20);
|
||||
|
||||
$zaprosov = 0;
|
||||
DB::listen(function () use (&$zaprosov): void {
|
||||
$zaprosov++;
|
||||
});
|
||||
|
||||
for ($i = 0; $i < 5000; $i++) {
|
||||
$rule->canSendNow(180, vtoroeOknoUtc('2026-08-03 09:00'));
|
||||
}
|
||||
|
||||
expect($rule->fromHour())->toBe(10);
|
||||
expect($rule->toHour())->toBe(20);
|
||||
expect($zaprosov)->toBe(0);
|
||||
});
|
||||
|
||||
it('бессмысленные границы снаружи правило не принимает', function () {
|
||||
expect(fn () => SmsQuietHours::withWindow(20, 10))->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Mail\LenaStorozhaTrevogaMail;
|
||||
use App\Services\SchedulerHeartbeatTracker;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
const SLOVO = 'слово-для-пробы-не-короче-тридцати-двух-знаков-0123456789';
|
||||
|
||||
beforeEach(function (): void {
|
||||
DB::connection('pgsql_supplier')->setPdo(DB::connection('pgsql')->getPdo());
|
||||
DB::connection('pgsql_supplier')->setReadPdo(DB::connection('pgsql')->getReadPdo());
|
||||
|
||||
config()->set('storozha_mashiny.tainoe_slovo', SLOVO);
|
||||
config()->set('storozha_mashiny.adresa', '');
|
||||
config()->set('storozha_mashiny.ozhidaem_storozhey', 3);
|
||||
config()->set('storozha_mashiny.svezhest_chasov', 26);
|
||||
config()->set('storozha_mashiny.imya_otmetki', 'lena:obhod-storozhey');
|
||||
config()->set('services.monitoring.alert_email', 'ops@liderra.ru');
|
||||
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- помощники --
|
||||
|
||||
/**
|
||||
* Итог ровно такой формы, какую пишет sutochnyy-obhod.sh на машине.
|
||||
* Числа проверок — те самые, что сторожа дают сегодня: 26 / 46 / 27.
|
||||
*/
|
||||
function itogSTremyaZelenymi(?string $sobranoV = null): string
|
||||
{
|
||||
return (string) json_encode([
|
||||
'shema' => 1,
|
||||
'sobrano_v' => $sobranoV ?? now()->toIso8601String(),
|
||||
'mashina' => 'fhmg5qpo4jconna4s9jo',
|
||||
'dlilos_ms' => 42000,
|
||||
'storozhey_vsego' => 3,
|
||||
'storozhey_krasnyh' => 0,
|
||||
'slepok_etalonov' => str_repeat('a', 64),
|
||||
'storozha' => [
|
||||
['imya' => 'proverka-raspisaniya', 'kod' => 0, 'proverok' => 26, 'provalov' => 0],
|
||||
['imya' => 'proverka-tekstovogo-sleda', 'kod' => 0, 'proverok' => 46, 'provalov' => 0],
|
||||
['imya' => 'proverka-chistki', 'kod' => 0, 'proverok' => 27, 'provalov' => 0],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
function postuchat(string $telo, ?string $podpis = null): TestResponse
|
||||
{
|
||||
$podpis ??= 'sha256='.hash_hmac('sha256', $telo, SLOVO);
|
||||
|
||||
return test()->call(
|
||||
'POST',
|
||||
'/api/webhook/lena-storozha',
|
||||
[], [], [],
|
||||
['CONTENT_TYPE' => 'application/json', 'HTTP_X_STOROZH_PODPIS' => $podpis],
|
||||
$telo,
|
||||
);
|
||||
}
|
||||
|
||||
function otmetka(): ?object
|
||||
{
|
||||
return DB::table('scheduler_heartbeats')->where('command_name', 'lena:obhod-storozhey')->first();
|
||||
}
|
||||
|
||||
function polozhitOtmetku(array $polya): void
|
||||
{
|
||||
DB::table('scheduler_heartbeats')->insert(array_merge([
|
||||
'command_name' => 'lena:obhod-storozhey',
|
||||
'last_run_at' => null,
|
||||
'last_success_at' => null,
|
||||
'last_error' => null,
|
||||
'runtime_ms' => 1000,
|
||||
'consecutive_failures' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
], $polya));
|
||||
}
|
||||
|
||||
// ============================================================ ПРИЁМ СТУКА ====
|
||||
|
||||
it('принимает подписанный зелёный итог и кладёт отметку', function (): void {
|
||||
$otvet = postuchat(itogSTremyaZelenymi());
|
||||
|
||||
$otvet->assertStatus(202);
|
||||
$o = otmetka();
|
||||
expect($o)->not->toBeNull()
|
||||
->and($o->last_error)->toBeNull()
|
||||
->and((int) $o->consecutive_failures)->toBe(0);
|
||||
});
|
||||
|
||||
it('🔴 считает возраст по дате ИЗ ИТОГА, а не по времени получения', function (): void {
|
||||
// Случай мимо допущения «стук дошёл — значит сторожа отработали»: сторожа
|
||||
// не запускались, а стук отправил ВЧЕРАШНИЙ файл. Отметка обязана взять
|
||||
// вчерашнюю дату, иначе портал считал бы всё свежим вечно.
|
||||
$vchera = now()->subHours(30);
|
||||
postuchat(itogSTremyaZelenymi($vchera->toIso8601String()))->assertStatus(202);
|
||||
|
||||
// 🪤 Сравниваем по модулю и в секундах. Первый заход этого сторожа стоял на
|
||||
// голом diffInMinutes(), а он у Carbon 3 знаковый: разница выходила -1800
|
||||
// и «меньше двух» проходило ВСЕГДА. Сторож был зелёным и не сторожил
|
||||
// ничего — поймано только тем, что его показали красным на подсунутой
|
||||
// ошибке (подмена sobrano_v на now() в контроллере).
|
||||
$o = otmetka();
|
||||
expect(abs(Carbon::parse($o->last_run_at)->getTimestamp() - $vchera->getTimestamp()))
|
||||
->toBeLessThan(2);
|
||||
});
|
||||
|
||||
it('гонит стук с негодной подписью', function (): void {
|
||||
postuchat(itogSTremyaZelenymi(), 'sha256='.str_repeat('f', 64))->assertStatus(404);
|
||||
expect(otmetka())->toBeNull();
|
||||
});
|
||||
|
||||
it('гонит стук вовсе без подписи', function (): void {
|
||||
$telo = itogSTremyaZelenymi();
|
||||
test()->call('POST', '/api/webhook/lena-storozha', [], [], [], ['CONTENT_TYPE' => 'application/json'], $telo)
|
||||
->assertStatus(404);
|
||||
expect(otmetka())->toBeNull();
|
||||
});
|
||||
|
||||
it('дорога закрыта, пока тайное слово не задано', function (): void {
|
||||
config()->set('storozha_mashiny.tainoe_slovo', '');
|
||||
postuchat(itogSTremyaZelenymi(), 'sha256='.hash_hmac('sha256', itogSTremyaZelenymi(), ''))
|
||||
->assertStatus(404);
|
||||
expect(otmetka())->toBeNull();
|
||||
});
|
||||
|
||||
it('гонит стук с чужого адреса, когда список задан', function (): void {
|
||||
config()->set('storozha_mashiny.adresa', '51.250.1.97, 10.0.0.0/8');
|
||||
postuchat(itogSTremyaZelenymi())->assertStatus(404);
|
||||
expect(otmetka())->toBeNull();
|
||||
});
|
||||
|
||||
it('🔴 называет ИМЯ покрасневшего сторожа', function (): void {
|
||||
$telo = (string) json_encode([
|
||||
'shema' => 1,
|
||||
'sobrano_v' => now()->toIso8601String(),
|
||||
'mashina' => 'fhmg5qpo4jconna4s9jo',
|
||||
'dlilos_ms' => 40000,
|
||||
'storozhey_vsego' => 3,
|
||||
'storozhey_krasnyh' => 1,
|
||||
'slepok_etalonov' => str_repeat('b', 64),
|
||||
'storozha' => [
|
||||
['imya' => 'proverka-raspisaniya', 'kod' => 1, 'proverok' => 26, 'provalov' => 3],
|
||||
['imya' => 'proverka-tekstovogo-sleda', 'kod' => 0, 'proverok' => 46, 'provalov' => 0],
|
||||
['imya' => 'proverka-chistki', 'kod' => 0, 'proverok' => 27, 'provalov' => 0],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
postuchat($telo)->assertStatus(202);
|
||||
|
||||
$o = otmetka();
|
||||
expect($o->last_error)->toContain('proverka-raspisaniya')
|
||||
->and($o->last_error)->not->toContain('proverka-chistki')
|
||||
->and((int) $o->consecutive_failures)->toBe(1);
|
||||
});
|
||||
|
||||
it('🔴 краснеет, если сторожей стало больше, чем мы ждём', function (): void {
|
||||
// Случай мимо допущения «сторожей три»: на машину положили четвёртого,
|
||||
// а портал по-прежнему ждёт троих. Молчать нельзя.
|
||||
$itog = json_decode(itogSTremyaZelenymi(), true);
|
||||
$itog['storozhey_vsego'] = 4;
|
||||
$itog['storozha'][] = ['imya' => 'proverka-zagotovok', 'kod' => 0, 'proverok' => 23, 'provalov' => 0];
|
||||
|
||||
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
|
||||
|
||||
expect(otmetka()->last_error)->toContain('сторожей в обходе 4, а ждём 3');
|
||||
});
|
||||
|
||||
it('краснеет на зелёном стороже, не сделавшем ни одной проверки', function (): void {
|
||||
$itog = json_decode(itogSTremyaZelenymi(), true);
|
||||
$itog['storozha'][1]['proverok'] = 0;
|
||||
|
||||
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
|
||||
|
||||
expect(otmetka()->last_error)->toContain('ничего не проверил');
|
||||
});
|
||||
|
||||
it('краснеет на дате сборки из будущего', function (): void {
|
||||
postuchat(itogSTremyaZelenymi(now()->addDays(3)->toIso8601String()))->assertStatus(202);
|
||||
|
||||
expect(otmetka()->last_error)->toContain('в будущем');
|
||||
});
|
||||
|
||||
it('краснеет и протухает, когда даты сборки нет вовсе', function (): void {
|
||||
$itog = json_decode(itogSTremyaZelenymi(), true);
|
||||
unset($itog['sobrano_v']);
|
||||
|
||||
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
|
||||
|
||||
$o = otmetka();
|
||||
expect($o->last_error)->toContain('дата сборки итога непонятна')
|
||||
->and(Carbon::parse($o->last_run_at)->year)->toBe(1970);
|
||||
});
|
||||
|
||||
it('краснеет на неизвестной разновидности итога', function (): void {
|
||||
postuchat((string) json_encode(['shema' => 99, 'sobrano_v' => now()->toIso8601String()]))
|
||||
->assertStatus(202);
|
||||
|
||||
expect(otmetka()->last_error)->toContain('непонятная разновидность');
|
||||
});
|
||||
|
||||
// ======================================= СВЯЗНОСТЬ ТОГО, ЧТО ПРИНЯЛИ ========
|
||||
//
|
||||
// 🔴 Нож владельца по допущению, на котором стояла защита: «список storozha
|
||||
// ПОЛОН, поэтому красноту можно пересчитать по нему, а собственное число
|
||||
// красных из итога читать незачем». Оба случая ниже годны по форме —
|
||||
// разновидность 1, дата свежая, подпись верна, адрес разрешён — и оба
|
||||
// проходили насквозь: портал записывал «всё хорошо», хотя машина в том же
|
||||
// теле прямо сказала обратное.
|
||||
|
||||
it('🔴 краснеет, когда в списке сторожей МЕНЬШЕ строк, чем объявлено', function (): void {
|
||||
// Случай Б ножа владельца: объявлено трое, в списке ОДИН зелёный.
|
||||
// Двое сторожей просто исчезли, и до починки портал молчал.
|
||||
$telo = (string) json_encode([
|
||||
'shema' => 1,
|
||||
'sobrano_v' => now()->toIso8601String(),
|
||||
'mashina' => 'fhmg5qpo4jconna4s9jo',
|
||||
'dlilos_ms' => 41000,
|
||||
'storozhey_vsego' => 3,
|
||||
'storozhey_krasnyh' => 2,
|
||||
'slepok_etalonov' => str_repeat('c', 64),
|
||||
'storozha' => [
|
||||
['imya' => 'proverka-raspisaniya', 'kod' => 0, 'proverok' => 26, 'provalov' => 0],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
postuchat($telo)->assertStatus(202);
|
||||
|
||||
// 🪤 Приводим к строке нарочно. На голом toContain() непочиненная дверь даёт
|
||||
// last_error = NULL, и Pest падает «Expected [iterable]» — по такому
|
||||
// красному не видно, дыра это или сторож сам сломан. Со строкой красный
|
||||
// честный: «'' не содержит ...».
|
||||
$o = otmetka();
|
||||
expect((string) $o->last_error)->toContain('строк в списке сторожей 1, а объявлено 3');
|
||||
expect($o->last_success_at)->toBeNull()
|
||||
->and((int) $o->consecutive_failures)->toBe(1);
|
||||
});
|
||||
|
||||
it('🔴 краснеет, когда объявленное число красных не сходится со списком', function (): void {
|
||||
// Случай В ножа владельца: машина объявила «красных 3», а все три строки
|
||||
// зелёные. Портал обязан заметить, что итог сам себе противоречит, а не
|
||||
// выбрасывать показание, которое ему дали.
|
||||
$telo = (string) json_encode([
|
||||
'shema' => 1,
|
||||
'sobrano_v' => now()->toIso8601String(),
|
||||
'mashina' => 'fhmg5qpo4jconna4s9jo',
|
||||
'dlilos_ms' => 41000,
|
||||
'storozhey_vsego' => 3,
|
||||
'storozhey_krasnyh' => 3,
|
||||
'slepok_etalonov' => str_repeat('d', 64),
|
||||
'storozha' => [
|
||||
['imya' => 'proverka-raspisaniya', 'kod' => 0, 'proverok' => 26, 'provalov' => 0],
|
||||
['imya' => 'proverka-tekstovogo-sleda', 'kod' => 0, 'proverok' => 46, 'provalov' => 0],
|
||||
['imya' => 'proverka-chistki', 'kod' => 0, 'proverok' => 27, 'provalov' => 0],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
postuchat($telo)->assertStatus(202);
|
||||
|
||||
expect((string) otmetka()->last_error)->toContain('красных по списку 0, а объявлено 3');
|
||||
});
|
||||
|
||||
it('🔴 контроль ножа: связный зелёный итог остаётся зелёным', function (): void {
|
||||
// Случай А ножа владельца. Без него две проверки выше доказывали бы только
|
||||
// то, что дверь научилась ругаться, — а не то, что она ругается по делу.
|
||||
$otvet = postuchat(itogSTremyaZelenymi());
|
||||
|
||||
$otvet->assertStatus(202)->assertJson(['status' => 'принят', 'bedy' => []]);
|
||||
expect(otmetka()->last_error)->toBeNull();
|
||||
});
|
||||
|
||||
it('🔴 краснеет на поле, о котором дверь не знает', function (): void {
|
||||
// Список «под защитой» считается ВЫЧИТАНИЕМ: всё, чего дверь не знает
|
||||
// поимённо, — новость, о которой надо сказать вслух. Иначе поле, добавленное
|
||||
// на машине и забытое здесь, окажется мимо защиты, а не под ней.
|
||||
$itog = json_decode(itogSTremyaZelenymi(), true);
|
||||
$itog['storozhey_propushcheno'] = 2;
|
||||
|
||||
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
|
||||
|
||||
expect((string) otmetka()->last_error)->toContain('storozhey_propushcheno');
|
||||
});
|
||||
|
||||
it('🔴 краснеет на поле строки сторожа, о котором дверь не знает', function (): void {
|
||||
$itog = json_decode(itogSTremyaZelenymi(), true);
|
||||
$itog['storozha'][1]['propushcheno'] = 4;
|
||||
|
||||
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
|
||||
|
||||
expect((string) otmetka()->last_error)->toContain('propushcheno');
|
||||
});
|
||||
|
||||
it('🔴 краснеет на список нужной длины, набитый одним и тем же сторожем', function (): void {
|
||||
// Второе место того же рода, о котором нож владельца не говорил: длину
|
||||
// списка можно добрать повторами. Тогда числа сходятся все до одного, а
|
||||
// на деле гоняли одного сторожа трижды.
|
||||
$itog = json_decode(itogSTremyaZelenymi(), true);
|
||||
$itog['storozha'][1]['imya'] = 'proverka-raspisaniya';
|
||||
$itog['storozha'][2]['imya'] = 'proverka-raspisaniya';
|
||||
|
||||
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
|
||||
|
||||
expect((string) otmetka()->last_error)->toContain('повторяются имена');
|
||||
});
|
||||
|
||||
it('🔴 разбирает НАСТОЯЩЕЕ тело, снятое с машины, а не только своё выдуманное', function (): void {
|
||||
// 🪤 Шов между двумя честными половинками. Все проверки выше кормят портал
|
||||
// телом, которое сами же и составили. Так можно доказать что угодно и
|
||||
// не заметить, что машина шлёт другое. Это тело снято 06.08.2026 с
|
||||
// настоящего обхода на машине 51.250.1.97 — байт в байт то, что дошло
|
||||
// до подставного слушателя на ней (проверено cmp) вместе с подписью.
|
||||
$telo = <<<'JSON'
|
||||
{
|
||||
"shema": 1,
|
||||
"sobrano_v": "2026-08-06T18:42:52Z",
|
||||
"mashina": "fhmg5qpo4jconna4s9jo",
|
||||
"dlilos_ms": 2000,
|
||||
"storozhey_vsego": 3,
|
||||
"storozhey_krasnyh": 0,
|
||||
"slepok_etalonov": "df0eedf9be30e6e5f81ecd1d7f4ef46ef0901e1d4563230b79b0778ef6236b23",
|
||||
"storozha": [
|
||||
{"imya": "proverka-chistki", "kod": 0, "proverok": 27, "provalov": 0},
|
||||
{"imya": "proverka-raspisaniya", "kod": 0, "proverok": 26, "provalov": 0},
|
||||
{"imya": "proverka-tekstovogo-sleda", "kod": 0, "proverok": 46, "provalov": 0}
|
||||
]
|
||||
}
|
||||
JSON;
|
||||
|
||||
postuchat($telo)->assertStatus(202);
|
||||
|
||||
$o = otmetka();
|
||||
expect($o)->not->toBeNull()
|
||||
->and($o->last_error)->toBeNull()
|
||||
->and(Carbon::parse($o->last_run_at)->toIso8601String())
|
||||
->toStartWith('2026-08-06T18:42:52');
|
||||
|
||||
// И тот же настоящий итог, состарившись, обязан поднять тревогу.
|
||||
DB::table('scheduler_heartbeats')
|
||||
->where('command_name', 'lena:obhod-storozhey')
|
||||
->update(['last_run_at' => now()->subHours(50)]);
|
||||
|
||||
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
|
||||
Mail::assertSent(LenaStorozhaTrevogaMail::class);
|
||||
});
|
||||
|
||||
// ====================================================== ЕЖЕДНЕВНАЯ ПРОВЕРКА ==
|
||||
|
||||
it('при здоровье письма НЕ шлёт', function (): void {
|
||||
polozhitOtmetku(['last_run_at' => now()->subHours(2), 'last_success_at' => now()->subHours(2)]);
|
||||
|
||||
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('🔴 шлёт письмо на ПРОТУХШИЙ итог, даже если внутри всё зелено', function (): void {
|
||||
// Тот самый нож: допущение «итог пришёл — значит машина жива». Итог
|
||||
// пришёл ВЧЕРА и был зелёным, сегодня машина молчит. Тревога обязана быть
|
||||
// от ВОЗРАСТА, а не от содержания.
|
||||
polozhitOtmetku([
|
||||
'last_run_at' => now()->subHours(40),
|
||||
'last_success_at' => now()->subHours(40),
|
||||
'last_error' => null,
|
||||
]);
|
||||
|
||||
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
|
||||
|
||||
Mail::assertSent(LenaStorozhaTrevogaMail::class, function (LenaStorozhaTrevogaMail $m) {
|
||||
return str_contains($m->prichina, 'ПРОТУХ') && $m->vozrastChasov >= 40;
|
||||
});
|
||||
});
|
||||
|
||||
it('🔴 шлёт письмо на красного сторожа и называет его', function (): void {
|
||||
polozhitOtmetku([
|
||||
'last_run_at' => now()->subHours(1),
|
||||
'last_error' => 'proverka-raspisaniya: код 1, проверок 26, красных 3',
|
||||
'consecutive_failures' => 1,
|
||||
]);
|
||||
|
||||
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
|
||||
|
||||
Mail::assertSent(LenaStorozhaTrevogaMail::class, function (LenaStorozhaTrevogaMail $m) {
|
||||
return str_contains($m->prichina, 'ПОКРАСНЕЛ')
|
||||
&& str_contains($m->podrobno, 'proverka-raspisaniya');
|
||||
});
|
||||
});
|
||||
|
||||
it('шлёт письмо, когда итога не было ни разу', function (): void {
|
||||
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
|
||||
|
||||
Mail::assertSent(LenaStorozhaTrevogaMail::class, function (LenaStorozhaTrevogaMail $m) {
|
||||
return str_contains($m->prichina, 'НЕ БЫЛО НИ РАЗУ');
|
||||
});
|
||||
});
|
||||
|
||||
it('🔴 шлёт письмо на адрес ИЗ НАСТРОЙКИ, а не на вписанный в код', function (): void {
|
||||
// Решение владельца Р117. Здешняя беда, которую не повторяем: соседний
|
||||
// сторож пульса шлёт на личную почту, ЖЁСТКО вписанную в его код.
|
||||
config()->set('services.monitoring.alert_email', 'ops-proba@liderra.ru');
|
||||
polozhitOtmetku(['last_run_at' => now()->subHours(99)]);
|
||||
|
||||
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
|
||||
|
||||
Mail::assertSent(LenaStorozhaTrevogaMail::class, function (LenaStorozhaTrevogaMail $m) {
|
||||
return $m->hasTo('ops-proba@liderra.ru');
|
||||
});
|
||||
expect(file_get_contents(app_path('Console/Commands/LenaProverkaObhodaStorozhey.php')))
|
||||
->not->toContain('kdv1@bk.ru');
|
||||
});
|
||||
|
||||
it('порог свежести берётся из настройки', function (): void {
|
||||
config()->set('storozha_mashiny.svezhest_chasov', 100);
|
||||
polozhitOtmetku(['last_run_at' => now()->subHours(40), 'last_success_at' => now()->subHours(40)]);
|
||||
|
||||
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
// ======================================================== ВТОРОЙ СЛОЙ ========
|
||||
|
||||
it('🔴 сама ежедневная проверка стоит под присмотром пульса', function (): void {
|
||||
// Иначе завели бы молчуна вторым слоем: портал ждёт стука, а что живо
|
||||
// само ожидание — не проверяет никто.
|
||||
expect(SchedulerHeartbeatTracker::EXPECTED_INTERVALS)
|
||||
->toHaveKey('lena:proverka-obhoda');
|
||||
});
|
||||
|
||||
it('🔴 команда стоит в расписании портала — зовущий у неё есть', function (): void {
|
||||
$raspisanie = file_get_contents(base_path('routes/console.php'));
|
||||
expect($raspisanie)->toContain("Schedule::command('lena:proverka-obhoda')");
|
||||
});
|
||||
@@ -0,0 +1,974 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Obzvon\IskhodObrashcheniya;
|
||||
use App\Services\Obzvon\ObzvonClient;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Сторожа портальной половины шва «портал зовёт голосового робота» — задача З-0.3.
|
||||
*
|
||||
* 🔴 Чего эти сторожа НЕ доказывают, говорю сразу и вслух:
|
||||
*
|
||||
* 1. **Что робот и вправду узнаёт повтор по знаку задания.** Здесь доказано
|
||||
* только то, что портал знак вырабатывает устойчиво, складывает его САМ (а
|
||||
* значит рассогласовать его с номером нечем), шлёт одно и то же и в теле, и в
|
||||
* двух заголовках. Замыкает защиту приёмник З-0.6, и он не здесь.
|
||||
* 2. **Что предел времени и вправду обрывает медленное чтение.** Подставной робот
|
||||
* подменяет собой весь перевозчик Guzzle — настоящие пределы времени при
|
||||
* подставном роботе не срабатывают в принципе. Здесь доказано поведение НА
|
||||
* обрыве и то, что оба предела заданы и не нулевые.
|
||||
* 3. **Что предел одновременных держит нагрузку на живой машине.** В тестах кэш
|
||||
* `array` — счёт мест идёт внутри одного процесса. Доказано, что счёт ведётся
|
||||
* ЗАМКАМИ ОБЩЕГО КЭША (чужой работник имитируется взятием того же замка
|
||||
* снаружи), а не числом в памяти клиента. На бою (`file`) те же замки — общие.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
// Ни один сторож не имеет права уйти в настоящую сеть.
|
||||
Http::preventStrayRequests();
|
||||
|
||||
config()->set('services.obzvon.allowed_hosts', ['robot.test', '127.0.0.1', 'localhost']);
|
||||
});
|
||||
|
||||
/** Клиент с разумными умолчаниями; любой довод перебивается на месте. */
|
||||
function klientObzvona(array $peremeny = []): ObzvonClient
|
||||
{
|
||||
return new ObzvonClient(
|
||||
endpoint: $peremeny['endpoint'] ?? 'https://robot.test/obzvon',
|
||||
key: $peremeny['key'] ?? 'tainyy-klyuch-robota',
|
||||
concurrency: $peremeny['concurrency'] ?? 4,
|
||||
waitSeconds: $peremeny['waitSeconds'] ?? 0,
|
||||
connectTimeout: $peremeny['connectTimeout'] ?? 5,
|
||||
timeout: $peremeny['timeout'] ?? 15,
|
||||
razreshyonnyeHosty: $peremeny['razreshyonnyeHosty'] ?? ['robot.test', '127.0.0.1', 'localhost'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Одно пробное задание — ровно то, ИЗ ЧЕГО клиент сам сложит знак.
|
||||
*
|
||||
* 🔴 Знака среди доводов нет и быть не может: `pozvonit` его не принимает.
|
||||
* Раньше сторожа звали `pozvonit(znakZadaniya(), '+79991234567')` — то есть
|
||||
* знак был посчитан от одного написания номера, а в тело ехало другое. Ровно
|
||||
* та рассогласованная пара, ради которой всё это переделано, жила прямо здесь.
|
||||
*/
|
||||
function zadanieProbnoe(array $peremeny = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'tenantId' => 1,
|
||||
'campaignId' => 7,
|
||||
'telefon' => '+7 999 123-45-67',
|
||||
'popytkaNo' => 1,
|
||||
], $peremeny);
|
||||
}
|
||||
|
||||
/** Тот знак, какой клиент обязан сложить сам для {@see zadanieProbnoe()}. */
|
||||
function znakZadaniya(): string
|
||||
{
|
||||
return ObzvonClient::klyuchZadaniya(1, 7, '+7 999 123-45-67', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Тела ВСЕХ заданий, ушедших роботу на ручку `pozvonit`, по порядку.
|
||||
*
|
||||
* 🪤 Сторожа ниже смотрят на то, что и вправду уехало в сеть, а не на текст
|
||||
* исходника: два помощника этой смены уже платили за сторожа, находившего своё
|
||||
* же пояснение в файле и зеленевшего при сломанном коде.
|
||||
*/
|
||||
function telaUshedshih(): array
|
||||
{
|
||||
return Http::recorded(fn (Request $z) => str_ends_with($z->url(), '/pozvonit'))
|
||||
->map(fn (array $para) => $para[0]->data())
|
||||
->all();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Проверка 1 плана: робот молчит — портал не падает
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('робот молчит — портал не падает, а говорит «не знаю»', function () {
|
||||
Http::fake(fn () => throw new ConnectionException('cURL error 7: Failed to connect'));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno)
|
||||
->and($otvet->poyasnenie)->toContain('робот молчит');
|
||||
});
|
||||
|
||||
it('🔴 молчание робота НЕ значит «звонка не было» — повторять вслепую нельзя', function () {
|
||||
Http::fake(fn () => throw new ConnectionException('cURL error 7: Failed to connect'));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
// Ровно тот случай, ради которого исходов пять: робот мог принять задание и
|
||||
// уже звонить, а ответ потерялся по дороге. Скажи клиент «не вышло» — живому
|
||||
// человеку позвонили бы второй раз.
|
||||
expect($otvet->zvonokMogSostoyatsya())->toBeTrue();
|
||||
});
|
||||
|
||||
it('🔴 клиент НЕ повторяет запрос сам — на молчание уходит ровно одно обращение', function () {
|
||||
// SelfRenderClient повторяет до двух раз, и там это правильно. Здесь повтор —
|
||||
// второй звонок живому человеку и вторые деньги.
|
||||
// 🪤 Http::assertSentCount здесь негоден: оборванный запрос Laravel в список
|
||||
// отправленных НЕ кладёт — счётчик показал бы ноль при любом числе повторов.
|
||||
// Считаем сами, прямо в подставном роботе.
|
||||
$skolko = 0;
|
||||
Http::fake(function () use (&$skolko) {
|
||||
$skolko++;
|
||||
throw new ConnectionException('оборвано');
|
||||
});
|
||||
|
||||
klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($skolko)->toBe(1);
|
||||
});
|
||||
|
||||
it('о молчании робота пишется строка уровня «предупреждение», а не ниже', function () {
|
||||
$zhurnal = Log::spy();
|
||||
Http::fake(fn () => throw new ConnectionException('оборвано'));
|
||||
|
||||
klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
// 🔴 На бою LOG_LEVEL=warning: всё ниже предупреждения не доезжает НИКОГДА
|
||||
// (оплачено 04.08.2026 — починка выглядела сделанной и была мёртвой).
|
||||
$zhurnal->shouldHaveReceived('warning');
|
||||
$zhurnal->shouldNotHaveReceived('info');
|
||||
$zhurnal->shouldNotHaveReceived('notice');
|
||||
$zhurnal->shouldNotHaveReceived('debug');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Проверка 2 плана: предел времени
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('выход за предел времени — «не знаю», работник очереди не держится дальше', function () {
|
||||
// Именно так выглядит выход за предел времени в Guzzle: ConnectionException.
|
||||
Http::fake(fn () => throw new ConnectionException('cURL error 28: Operation timed out after 15001 ms'));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno)
|
||||
->and($otvet->poyasnenie)->toContain('timed out');
|
||||
});
|
||||
|
||||
it('🔴 пределов времени ДВА, и оба не нулевые', function () {
|
||||
// Сторож против тихого удаления второго предела. connect_timeout ловит только
|
||||
// «не дозвонились до машины»; «робот отдаёт ответ по байту бесконечно» ловит
|
||||
// ТОЛЬКО общий timeout — соединение в этом случае установлено.
|
||||
$nastroyki = require __DIR__.'/../../../config/services.php';
|
||||
|
||||
expect($nastroyki['obzvon'])->toHaveKey('connect_timeout')
|
||||
->and($nastroyki['obzvon'])->toHaveKey('timeout')
|
||||
->and($nastroyki['obzvon']['connect_timeout'])->toBeGreaterThan(0)
|
||||
->and($nastroyki['obzvon']['timeout'])->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Проверка 3 плана: пустой адрес или ключ — дверь закрыта
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('пустой адрес — наружу не ходим, звонка точно не было', function () {
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona(['endpoint' => ''])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('пустой ключ — наружу не ходим', function () {
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona(['key' => ''])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
|
||||
->and($otvet->poyasnenie)->toContain('тайный ключ');
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('закрытая дверь не сыплет исключениями и не притворяется успехом', function () {
|
||||
Http::fake();
|
||||
$klient = klientObzvona(['endpoint' => '']);
|
||||
|
||||
expect($klient->pozvonit(...zadanieProbnoe())->poluchenLi())->toBeFalse()
|
||||
->and($klient->sostoyanie('c-1')->poluchenLi())->toBeFalse()
|
||||
->and($klient->ostanovit('c-1')->poluchenLi())->toBeFalse();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Условленное слово — урок self_render
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('🔴 ответ 200 и мусором — это НЕ успех', function () {
|
||||
// Тот самый урок: «ответ меньше 500 = жив» физически не мог покраснеть.
|
||||
Http::fake(fn () => Http::response(['ерунда' => 'какая-то'], 200));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->poluchenLi())->toBeFalse();
|
||||
});
|
||||
|
||||
it('🔴 ответ 200 и мусором — это и НЕ отказ, а «не знаю»', function () {
|
||||
// Тело мог покалечить посредник уже ПОСЛЕ того, как робот принял задание.
|
||||
// Записать мусор в «отказ» значит вернуть дыру с двойным звонком.
|
||||
Http::fake(fn () => Http::response('совсем не json', 200));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeTrue();
|
||||
});
|
||||
|
||||
it('робот сказал «нет» — это отказ, и звонка точно не было', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => false, 'reason' => 'кампания не найдена'], 200));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Otkaz)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse()
|
||||
->and($otvet->poyasnenie)->toContain('кампания не найдена');
|
||||
});
|
||||
|
||||
it('робот сломался 5xx — «не знаю», а не «не было»', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => false], 500));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeTrue();
|
||||
});
|
||||
|
||||
it('робот ответил 4xx — понял и отказался, звонка не было', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => false], 422));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Otkaz)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
|
||||
});
|
||||
|
||||
it('робот сказал условленное слово — задание принято, внешний номер запомнен', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'rob-42'], 200));
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen)
|
||||
->and($otvet->vneshniyIdZvonka)->toBe('rob-42');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 🔴 Опознавательный знак задания — защита от двойного звонка
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('🔴 знак задания ОДИНАКОВ у повтора той же попытки', function () {
|
||||
// Без этого повтор после потерянного ответа робот не отличит от нового
|
||||
// задания и наберёт номер второй раз.
|
||||
expect(ObzvonClient::klyuchZadaniya(1, 7, '+79991234567', 1))
|
||||
->toBe(ObzvonClient::klyuchZadaniya(1, 7, '+7 (999) 123-45-67', 1));
|
||||
});
|
||||
|
||||
it('🔴 знак задания РАЗНЫЙ у разной попытки, номера, кампании и арендатора', function () {
|
||||
// Вторая попытка дозвона — законный второй звонок, глушить его нельзя.
|
||||
$osnova = ObzvonClient::klyuchZadaniya(1, 7, '+79991234567', 1);
|
||||
|
||||
expect(ObzvonClient::klyuchZadaniya(1, 7, '+79991234567', 2))->not->toBe($osnova)
|
||||
->and(ObzvonClient::klyuchZadaniya(1, 7, '+79990000000', 1))->not->toBe($osnova)
|
||||
->and(ObzvonClient::klyuchZadaniya(1, 8, '+79991234567', 1))->not->toBe($osnova)
|
||||
->and(ObzvonClient::klyuchZadaniya(2, 7, '+79991234567', 1))->not->toBe($osnova);
|
||||
});
|
||||
|
||||
it('🔴 в знаке задания нет телефона человека — он необратим', function () {
|
||||
expect(ObzvonClient::klyuchZadaniya(1, 7, '+79991234567', 1))->not->toContain('9991234567');
|
||||
});
|
||||
|
||||
it('знак задания уходит роботу двумя заголовками, а ключ — своим', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
$znak = znakZadaniya();
|
||||
|
||||
klientObzvona()->pozvonit(...zadanieProbnoe());
|
||||
|
||||
Http::assertSent(fn (Request $z) => $z->hasHeader('X-Obzvon-Zadanie', $znak)
|
||||
&& $z->hasHeader('Idempotency-Key', $znak)
|
||||
&& $z->hasHeader('X-Obzvon-Key', 'tainyy-klyuch-robota'));
|
||||
});
|
||||
|
||||
it('задание без номера — не звоним', function () {
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => ' ']));
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('🔴 номер без единой цифры — не звоним, и это ошибка в журнале', function () {
|
||||
// 🪤 Раньше сюда проваливалось всё, где нет цифр: `trim` такую строку не
|
||||
// ловил, а знак от неё получался ОДИН И ТОТ ЖЕ у любых таких заданий —
|
||||
// цифр ноль, хешу нечего различать. Два разных задания столкнулись бы
|
||||
// знаками у робота, и одному из людей не позвонили бы вовсе.
|
||||
$zhurnal = Log::spy();
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => 'абв']));
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
|
||||
Http::assertNothingSent();
|
||||
$zhurnal->shouldHaveReceived('error');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 🔴🔴 ЗНАК И ТЕЛО ЗАДАНИЯ БОЛЬШЕ НЕ МОГУТ РАЗОЙТИСЬ
|
||||
//
|
||||
// Что было замерено до починки: знак считался от ОДНИХ ЦИФР телефона, а в тело
|
||||
// задания клался номер КАК ЕСТЬ. Один и тот же человек, записанный
|
||||
// `+7 999 000-00-01` и `79990000001`, давал ОДИН знак и РАЗНЫЕ тела. Приёмник
|
||||
// робота (З-0.6) снимает отпечаток с тела дословно и на такую пару отвечает 422
|
||||
// — честный повтор получал отказ вместо звонка.
|
||||
//
|
||||
// И вторая половина той же беды: знак приходил ОТДЕЛЬНЫМ доводом от телефона.
|
||||
// Работник очереди, посчитавший знак один раз и перебирающий номера, отправил
|
||||
// бы роботу рассогласованную пару, и человеку не позвонили бы МОЛЧА.
|
||||
//
|
||||
// 🪤 Сторожа ниже смотрят на то, что ушло в сеть подставному роботу, — на
|
||||
// работу, а не на слова в исходнике.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('🔴 А (контроль): одно задание дважды подряд — знак совпал и тело совпало', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
$klient = klientObzvona();
|
||||
$klient->pozvonit(...zadanieProbnoe());
|
||||
$klient->pozvonit(...zadanieProbnoe());
|
||||
|
||||
$tela = telaUshedshih();
|
||||
|
||||
expect($tela)->toHaveCount(2)
|
||||
->and($tela[1]['zadanie'])->toBe($tela[0]['zadanie'])
|
||||
->and($tela[1]['telefon'])->toBe($tela[0]['telefon']);
|
||||
});
|
||||
|
||||
it('🔴 Б: тот же человек, записанный двумя видами — и знак, и ТЕЛО совпадают', function () {
|
||||
// 🔴 Тот самый нож. До починки совпадал только знак: тело ехало «как есть»,
|
||||
// и робот видел два разных задания под одним знаком → 422 честному повтору.
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
$klient = klientObzvona();
|
||||
$klient->pozvonit(...zadanieProbnoe(['telefon' => '+7 999 000-00-01']));
|
||||
$klient->pozvonit(...zadanieProbnoe(['telefon' => '79990000001']));
|
||||
|
||||
$tela = telaUshedshih();
|
||||
|
||||
expect($tela)->toHaveCount(2)
|
||||
->and($tela[1]['zadanie'])->toBe($tela[0]['zadanie'])
|
||||
->and($tela[1]['telefon'])->toBe($tela[0]['telefon']);
|
||||
});
|
||||
|
||||
it('🔴 В: рассогласовать знак и телефон нечем — знак снаружи не задаётся вовсе', function () {
|
||||
// Проверяем не текст файла, а САМ ДОВОДНЫЙ ряд метода: если кто-нибудь
|
||||
// вернёт знак отдельным доводом, сторож покраснеет.
|
||||
$dovody = array_map(
|
||||
fn (ReflectionParameter $p) => $p->getName(),
|
||||
(new ReflectionMethod(ObzvonClient::class, 'pozvonit'))->getParameters(),
|
||||
);
|
||||
|
||||
expect($dovody)->toBe(['tenantId', 'campaignId', 'telefon', 'popytkaNo', 'dopolnitelno']);
|
||||
});
|
||||
|
||||
it('🔴 В: чем клиента ни зови — знак в теле всегда сходится с телефоном в теле', function () {
|
||||
// Главный сторож починки. Он проверяет САМО СВОЙСТВО, ради которого всё
|
||||
// сделано: для КАЖДОГО ушедшего задания знак обязан быть тем самым, какой
|
||||
// считается от номера, уехавшего в этом же теле. Разойдись они хоть на один
|
||||
// знак — робот ответит 422, и человеку не позвонят.
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
$vsyakie = [
|
||||
['telefon' => '+7 999 000-00-01'],
|
||||
['telefon' => '79990000001'],
|
||||
['telefon' => ' 8 (999) 000 00 01 '],
|
||||
['tenantId' => 42, 'telefon' => '+7-999-000-00-01'],
|
||||
['campaignId' => 99, 'telefon' => '+7 999 000 00 01'],
|
||||
['popytkaNo' => 3, 'telefon' => '+7 999 000-00-01'],
|
||||
];
|
||||
|
||||
$klient = klientObzvona();
|
||||
|
||||
foreach ($vsyakie as $peremeny) {
|
||||
$klient->pozvonit(...zadanieProbnoe($peremeny));
|
||||
}
|
||||
|
||||
$tela = telaUshedshih();
|
||||
|
||||
expect($tela)->toHaveCount(count($vsyakie));
|
||||
|
||||
foreach ($tela as $nomer => $telo) {
|
||||
$zadanie = zadanieProbnoe($vsyakie[$nomer]);
|
||||
|
||||
// 🪤 Первая половина обязательна отдельно, и вот почему. `klyuchZadaniya`
|
||||
// сама приводит номер к одному виду — значит одной этой сверки МАЛО:
|
||||
// положи клиент в тело номер с плюсами и скобками, знак всё равно сошёлся
|
||||
// бы, а робот сравнивает тело ДОСЛОВНО и ответил бы 422. Замерено: без
|
||||
// этой строки сторож оставался зелёным при возвращённой беде.
|
||||
expect($telo['telefon'])->toMatch('/^\d+$/');
|
||||
|
||||
expect($telo['zadanie'])->toBe(ObzvonClient::klyuchZadaniya(
|
||||
$zadanie['tenantId'],
|
||||
$zadanie['campaignId'],
|
||||
$telo['telefon'], // 🔴 именно тот номер, что уехал роботу
|
||||
$zadanie['popytkaNo'],
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
it('🔴 Г: вторая попытка по тому же номеру — знак ДРУГОЙ, звонок законный', function () {
|
||||
// Вторая попытка дозвона — законный второй звонок, глушить его нельзя.
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
$klient = klientObzvona();
|
||||
$klient->pozvonit(...zadanieProbnoe(['popytkaNo' => 1]));
|
||||
$otvet = $klient->pozvonit(...zadanieProbnoe(['popytkaNo' => 2]));
|
||||
|
||||
$tela = telaUshedshih();
|
||||
|
||||
expect($tela)->toHaveCount(2)
|
||||
->and($tela[1]['zadanie'])->not->toBe($tela[0]['zadanie'])
|
||||
->and($tela[1]['telefon'])->toBe($tela[0]['telefon'])
|
||||
->and($otvet->poluchenLi())->toBeTrue();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 🔴🔴 Р127 — ВОСЬМЁРКА К СЕМЁРКЕ, И ТОЛЬКО ПРИ ОДИННАДЦАТИ ЦИФРАХ
|
||||
//
|
||||
// Что было замерено ДО починки: `8 999 000-00-01` и `+7 999 000-00-01` — один и
|
||||
// тот же живой человек, записанный двумя обычными способами, — давали ДВА разных
|
||||
// знака и два разных тела. Ему позвонили бы дважды, и клиент заплатил бы за два
|
||||
// звонка вместо одного.
|
||||
//
|
||||
// Решение владельца Р127: приводить `8` к `7` ТОЛЬКО когда цифр ровно
|
||||
// одиннадцать. Короткие и длинные не трогать вовсе — там `8` может значить что
|
||||
// угодно, и слияние двух РАЗНЫХ людей в один знак было бы ошибкой в обратную
|
||||
// сторону: второму не позвонили бы никогда и МОЛЧА.
|
||||
//
|
||||
// 🪤 Сторожа ниже сверяют то, что и вправду уехало подставному роботу, и
|
||||
// сверяют ДОСЛОВНО — так же, как сравнивает приёмник. Сверка «с точностью до
|
||||
// приведения» была бы слепа ровно к той половине беды, ради которой пишется.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('🔴 Р127 А (нож): восьмёрка и плюс-семь — ОДИН человек, один знак и одно тело', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
$klient = klientObzvona();
|
||||
$klient->pozvonit(...zadanieProbnoe(['telefon' => '8 999 000-00-01']));
|
||||
$klient->pozvonit(...zadanieProbnoe(['telefon' => '+7 999 000-00-01']));
|
||||
|
||||
$tela = telaUshedshih();
|
||||
|
||||
expect($tela)->toHaveCount(2)
|
||||
->and($tela[1]['zadanie'])->toBe($tela[0]['zadanie'])
|
||||
->and($tela[1]['telefon'])->toBe($tela[0]['telefon'])
|
||||
->and($tela[0]['telefon'])->toBe('79990000001');
|
||||
});
|
||||
|
||||
it('🔴 Р127 Б (контроль): что совпадало до починки — совпадает и после', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
$klient = klientObzvona();
|
||||
$klient->pozvonit(...zadanieProbnoe(['telefon' => '+7 (999) 000-00-01']));
|
||||
$klient->pozvonit(...zadanieProbnoe(['telefon' => '79990000001']));
|
||||
|
||||
$tela = telaUshedshih();
|
||||
|
||||
expect($tela)->toHaveCount(2)
|
||||
->and($tela[1]['zadanie'])->toBe($tela[0]['zadanie'])
|
||||
->and($tela[1]['telefon'])->toBe($tela[0]['telefon']);
|
||||
});
|
||||
|
||||
it('🔴 Р127 В (граница): одиннадцать цифр НЕ с восьмёрки — не трогаем', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => '79990000001']));
|
||||
|
||||
expect(telaUshedshih()[0]['telefon'])->toBe('79990000001');
|
||||
});
|
||||
|
||||
it('🔴 Р127 Г (граница): начинается на 8, но цифр НЕ одиннадцать — не трогаем вовсе', function () {
|
||||
// 🔴 Ровно то, что владелец отрезал сознательно. Короткий `8495123` — это не
|
||||
// «российский мобильный без кода»: подставь мы туда семёрку, два РАЗНЫХ
|
||||
// человека могли бы слиться в один знак, и второму не позвонили бы МОЛЧА.
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
$klient = klientObzvona();
|
||||
$klient->pozvonit(...zadanieProbnoe(['telefon' => '8495123']));
|
||||
$klient->pozvonit(...zadanieProbnoe(['telefon' => '8999000000012']));
|
||||
|
||||
$tela = telaUshedshih();
|
||||
|
||||
expect($tela)->toHaveCount(2)
|
||||
->and($tela[0]['telefon'])->toBe('8495123')
|
||||
->and($tela[1]['telefon'])->toBe('8999000000012');
|
||||
});
|
||||
|
||||
it('🔴 Р127 Г: длинный номер с восьмёрки НЕ сливается со своим семёрочным двойником', function () {
|
||||
// Сторож против «улучшения» правила: сними кто-нибудь проверку длины — эти
|
||||
// два знака стали бы одним, и одному из двух людей не позвонили бы вовсе.
|
||||
expect(ObzvonClient::klyuchZadaniya(1, 7, '8999000000012', 1))
|
||||
->not->toBe(ObzvonClient::klyuchZadaniya(1, 7, '7999000000012', 1));
|
||||
|
||||
expect(ObzvonClient::klyuchZadaniya(1, 7, '8495123', 1))
|
||||
->not->toBe(ObzvonClient::klyuchZadaniya(1, 7, '7495123', 1));
|
||||
});
|
||||
|
||||
it('🔴 Р127 Д (граница): строка без цифр — дверь по-прежнему закрыта', function () {
|
||||
// Это уже было построено до Р127 и обязано пережить починку: восьмёрка не
|
||||
// должна была открыть дверь тому, кому она закрыта.
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => 'абв']));
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('🔴 Р127 Е: вторая попытка по восьмёрочному номеру — знак ДРУГОЙ, звонок законный', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
$klient = klientObzvona();
|
||||
$klient->pozvonit(...zadanieProbnoe(['telefon' => '8 999 000-00-01', 'popytkaNo' => 1]));
|
||||
$otvet = $klient->pozvonit(...zadanieProbnoe(['telefon' => '8 999 000-00-01', 'popytkaNo' => 2]));
|
||||
|
||||
$tela = telaUshedshih();
|
||||
|
||||
expect($tela)->toHaveCount(2)
|
||||
->and($tela[1]['zadanie'])->not->toBe($tela[0]['zadanie'])
|
||||
->and($tela[1]['telefon'])->toBe($tela[0]['telefon'])
|
||||
->and($otvet->poluchenLi())->toBeTrue();
|
||||
});
|
||||
|
||||
it('🔴 Р127: приведение НЕ портит знак дважды — второй прогон ничего не меняет', function () {
|
||||
// 🪤 `pozvonit` приводит номер, а `klyuchZadaniya` приводит его ВТОРОЙ раз
|
||||
// уже приведённым. Не будь правило устойчивым к повтору, знак в теле и знак,
|
||||
// посчитанный от тела, разошлись бы — и робот ответил бы 422.
|
||||
expect(ObzvonClient::klyuchZadaniya(1, 7, '8 999 000-00-01', 1))
|
||||
->toBe(ObzvonClient::klyuchZadaniya(1, 7, '79990000001', 1));
|
||||
});
|
||||
|
||||
it('🔴 знак в заголовках — тот же самый, что в теле', function () {
|
||||
// Приёмник сверяет тело с заголовком и на расхождении отвечает 400. Разойдись
|
||||
// они здесь — не дозвонились бы вовсе, и по коду 400 причину не узнать.
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => '+7 999 000-00-01']));
|
||||
|
||||
$telo = telaUshedshih()[0];
|
||||
|
||||
Http::assertSent(fn (Request $z) => $z->hasHeader('X-Obzvon-Zadanie', $telo['zadanie'])
|
||||
&& $z->hasHeader('Idempotency-Key', $telo['zadanie']));
|
||||
});
|
||||
|
||||
it('🔴 в теле задания номер без украшений — того же вида, из какого посчитан знак', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => '+7 (999) 000-00-01']));
|
||||
|
||||
expect(telaUshedshih()[0]['telefon'])->toBe('79990000001');
|
||||
});
|
||||
|
||||
it('🔴 «что ещё сказать роботу» не может перебить знак или номер', function () {
|
||||
// Отпечаток тела у робота снимается со ВСЕГО тела. Дай мы `dopolnitelno`
|
||||
// перебить `zadanie` или `telefon` — рассогласование вернулось бы с другого
|
||||
// конца, уже никем не охраняемое.
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
klientObzvona()->pozvonit(...zadanieProbnoe([
|
||||
'dopolnitelno' => ['zadanie' => 'z-chuzhoy', 'telefon' => '+70000000000', 'skript' => 'privet'],
|
||||
]));
|
||||
|
||||
$telo = telaUshedshih()[0];
|
||||
|
||||
expect($telo['zadanie'])->toBe(znakZadaniya())
|
||||
->and($telo['telefon'])->toBe('79991234567')
|
||||
->and($telo['skript'])->toBe('privet');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 🔴 Адрес с опечаткой уводит тайный ключ чужому
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('🔴 адрес ведёт на неразрешённый хост — ключ чужому НЕ уходит', function () {
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona(['endpoint' => 'https://rrobot.test/obzvon'])
|
||||
->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
|
||||
->and($otvet->poyasnenie)->toContain('неразрешённый хост');
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('🔴 пустой список разрешённых хостов закрывает всё, а не открывает', function () {
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona(['razreshyonnyeHosty' => []])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('адрес без хоста вовсе — дверь закрыта', function () {
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona(['endpoint' => 'просто-строка'])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 🔴 Р123: к роботу только по шифрованной дороге, кроме своей машины
|
||||
//
|
||||
// Замерено прибором приёмки 07.08.2026: разрешённый хост + `http://` уходил
|
||||
// наружу молча, и тайный ключ ехал в заголовке ОТКРЫТЫМ ТЕКСТОМ. Одна опечатка
|
||||
// в `OBZVON_ENDPOINT` — и ключ у чужого человека, без единого признака.
|
||||
//
|
||||
// 🪤 Каждый случай меряется ОТДЕЛЬНО, своим подставным роботом: на прошлой
|
||||
// приёмке подставной робот протёк из случая в случай и два замера были ложью.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('🔴 ж: разрешённый хост, но http без шифра — ключ наружу НЕ уходит', function () {
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://51.250.1.97/lena',
|
||||
'razreshyonnyeHosty' => ['51.250.1.97'],
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse()
|
||||
->and($otvet->poyasnenie)->toContain('без шифрования');
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('🔴 з: тот же хост по https — работает и после запрета', function () {
|
||||
// Контроль. Без него запрет мог бы закрыть дверь вообще всем и выглядеть
|
||||
// «зелёным»: живой робот на 51.250.1.97 слушает 443 уже сегодня.
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'rob-7'], 200));
|
||||
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'https://51.250.1.97/lena',
|
||||
'razreshyonnyeHosty' => ['51.250.1.97'],
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen)
|
||||
->and($otvet->vneshniyIdZvonka)->toBe('rob-7');
|
||||
});
|
||||
|
||||
it('🔴 и: свой компьютер, http на localhost — работает, разработка не встаёт', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'mestnyy'], 200));
|
||||
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://localhost:8080/lena',
|
||||
'razreshyonnyeHosty' => ['localhost'],
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen);
|
||||
});
|
||||
|
||||
it('🔴 и: свой компьютер, http на 127.0.0.1 — работает', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'mestnyy'], 200));
|
||||
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://127.0.0.1:8080/lena',
|
||||
'razreshyonnyeHosty' => ['127.0.0.1'],
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen);
|
||||
});
|
||||
|
||||
it('🔴 к: тот же местный адрес НА БОЕВОМ — дверь закрыта', function () {
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://127.0.0.1:8080/lena',
|
||||
'razreshyonnyeHosty' => ['127.0.0.1'],
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 🔴 Сторож на само исключение: «своя машина» — ДВА независимых ответа
|
||||
//
|
||||
// Владелец назвал незакрытым: «ошибись кто-нибудь настройкой на боевом так,
|
||||
// чтобы портал счёл себя своей машиной, — запрет отвалится, и сторожа на это
|
||||
// нет». Сторож — здесь. Чтобы ключ уехал открытым, обязаны сломаться ОБА
|
||||
// ответа сразу: и «где я живу», и «робот стоит на этой же машине». Одной
|
||||
// опечатки в APP_ENV на боевом для утечки НЕ хватает.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('🔴 APP_ENV=local на боевой машине НЕ открывает http к удалённому роботу', function () {
|
||||
// Ровно тот случай: настройку окружения перепутали, портал считает себя
|
||||
// своей машиной. Робот при этом на чужом железе — ключ поехал бы в интернет.
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://51.250.1.97/lena',
|
||||
'razreshyonnyeHosty' => ['51.250.1.97'],
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('🔴 «своя машина» — не список слов: похожее имя хоста своей машиной не считается', function () {
|
||||
// `localhost.zloumyshlennik.ru` кончается на что угодно и начинается со
|
||||
// слова «localhost». Проверкой «есть ли в строке localhost» он бы прошёл.
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://localhost.zloumyshlennik.ru/lena',
|
||||
'razreshyonnyeHosty' => ['localhost.zloumyshlennik.ru'],
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('🔴 вся петля 127.0.0.0/8 — своя машина, а соседний 127 в другом месте адреса — нет', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
Http::fake(fn () => Http::response(['ok' => true], 200));
|
||||
|
||||
// 127.0.0.1 — не единственный адрес петли; сервер разработчика может жить
|
||||
// на 127.0.0.53 (systemd-resolved) или 127.0.1.1 (Debian).
|
||||
expect(klientObzvona([
|
||||
'endpoint' => 'http://127.0.1.1:8080/lena',
|
||||
'razreshyonnyeHosty' => ['127.0.1.1'],
|
||||
])->pozvonit(...zadanieProbnoe())->poluchenLi())->toBeTrue();
|
||||
});
|
||||
|
||||
it('🔴 адрес, похожий на петлю, но ею не являющийся — дверь закрыта', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
Http::fake();
|
||||
|
||||
// 127 внутри, но это чужая машина в интернете.
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://10.0.127.1:8080/lena',
|
||||
'razreshyonnyeHosty' => ['10.0.127.1'],
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('🔴 запрет http на боевом виден в журнале, и уровень не ниже предупреждения', function () {
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
$zhurnal = Log::spy();
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://51.250.1.97/lena',
|
||||
'razreshyonnyeHosty' => ['51.250.1.97'],
|
||||
])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
// 🪤 Сперва — что дверь закрыта ИМЕННО из-за шифрования, а уж потом про
|
||||
// журнал. Без первой строки сторож зеленел бы от чужого предупреждения в
|
||||
// том же походе: сломай запрет — запрос уйдёт, робот ответит не тем, о чём
|
||||
// договаривались, и строка `warning` появится сама, не о том.
|
||||
expect($otvet->poyasnenie)->toContain('без шифрования');
|
||||
Http::assertNothingSent();
|
||||
|
||||
// На бою LOG_LEVEL=warning: молчаливый отказ звонить был бы вторым сортом
|
||||
// той же беды — обзвон встал бы, и никто бы не узнал почему.
|
||||
$zhurnal->shouldHaveReceived('warning');
|
||||
$zhurnal->shouldNotHaveReceived('info');
|
||||
$zhurnal->shouldNotHaveReceived('notice');
|
||||
$zhurnal->shouldNotHaveReceived('debug');
|
||||
});
|
||||
|
||||
it('🔴 умолчание настроек не открывает http: в .env.example адрес робота шифрованный', function () {
|
||||
// Сторож против тихого возврата дыры через образец настроек: скопировал
|
||||
// .env.example — получил открытую дорогу.
|
||||
$obrazec = (string) file_get_contents(__DIR__.'/../../../.env.example');
|
||||
|
||||
expect($obrazec)->not->toMatch('/^OBZVON_ENDPOINT=http:\/\//m');
|
||||
});
|
||||
|
||||
it('🔴 проверка чужого удостоверения не отключена — иначе https был бы одной видимостью', function () {
|
||||
// Та же беда с другого конца: `https://` в адресе, а проверку удостоверения
|
||||
// машины кто-то снял, чтобы «заработало с самодельным сертификатом». Тогда
|
||||
// шифрование есть, а подставиться на дороге может кто угодно — и ключ
|
||||
// уедет ему. Laravel проверяет удостоверение по умолчанию; сторож стоит на
|
||||
// том, чтобы эту проверку тут никогда не сняли.
|
||||
$ishodnik = (string) file_get_contents(__DIR__.'/../../../app/Services/Obzvon/ObzvonClient.php');
|
||||
|
||||
expect($ishodnik)->not->toContain('withoutVerifying')
|
||||
->and($ishodnik)->not->toContain("'verify' => false");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Проверка 4 плана: предел одновременных заданий
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('🔴 все места заняты — исход «занято», наружу не ходили', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true], 200));
|
||||
|
||||
// Чужие работники очереди — берём те же замки СНАРУЖИ клиента. Если бы предел
|
||||
// жил числом в памяти клиента, этот сторож не покраснел бы никогда.
|
||||
$chuzhie = [];
|
||||
foreach ([0, 1] as $nomer) {
|
||||
$zamok = Cache::lock('obzvon:mesto:'.$nomer, 60);
|
||||
expect($zamok->get())->toBeTrue();
|
||||
$chuzhie[] = $zamok;
|
||||
}
|
||||
|
||||
$otvet = klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Zanyato)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
|
||||
Http::assertNothingSent();
|
||||
|
||||
foreach ($chuzhie as $zamok) {
|
||||
$zamok->release();
|
||||
}
|
||||
});
|
||||
|
||||
it('освободилось место — задание проходит', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
$zanyatye = [];
|
||||
foreach ([0, 1] as $nomer) {
|
||||
$zamok = Cache::lock('obzvon:mesto:'.$nomer, 60);
|
||||
$zamok->get();
|
||||
$zanyatye[] = $zamok;
|
||||
}
|
||||
|
||||
$zanyatye[1]->release();
|
||||
|
||||
expect(klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe())->poluchenLi())
|
||||
->toBeTrue();
|
||||
|
||||
$zanyatye[0]->release();
|
||||
});
|
||||
|
||||
it('🔴 лишнее задание ЖДЁТ свободного места, а не отбивается сразу', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true], 200));
|
||||
|
||||
$zamok = Cache::lock('obzvon:mesto:0', 60);
|
||||
$zamok->get();
|
||||
|
||||
$nachalo = microtime(true);
|
||||
$otvet = klientObzvona(['concurrency' => 1, 'waitSeconds' => 1])
|
||||
->pozvonit(...zadanieProbnoe());
|
||||
$proshlo = microtime(true) - $nachalo;
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Zanyato)
|
||||
->and($proshlo)->toBeGreaterThanOrEqual(1.0);
|
||||
|
||||
$zamok->release();
|
||||
});
|
||||
|
||||
it('место возвращается после успешного обращения', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
foreach ([0, 1] as $nomer) {
|
||||
$zamok = Cache::lock('obzvon:mesto:'.$nomer, 60);
|
||||
expect($zamok->get())->toBeTrue();
|
||||
$zamok->release();
|
||||
}
|
||||
});
|
||||
|
||||
it('🔴 место возвращается, даже когда робот молчит', function () {
|
||||
// Иначе одна беда навсегда съедала бы место, и обзвон затухал бы по одному.
|
||||
Http::fake(fn () => throw new ConnectionException('оборвано'));
|
||||
|
||||
klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe());
|
||||
|
||||
foreach ([0, 1] as $nomer) {
|
||||
$zamok = Cache::lock('obzvon:mesto:'.$nomer, 60);
|
||||
expect($zamok->get())->toBeTrue();
|
||||
$zamok->release();
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Три умения ходят по своим ручкам
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('три умения зовут три разные ручки робота', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
$klient = klientObzvona();
|
||||
|
||||
$klient->pozvonit(...zadanieProbnoe());
|
||||
$klient->sostoyanie('rob-42');
|
||||
$klient->ostanovit('rob-42', 'человек попросил');
|
||||
|
||||
Http::assertSent(fn (Request $z) => $z->url() === 'https://robot.test/obzvon/pozvonit');
|
||||
Http::assertSent(fn (Request $z) => $z->url() === 'https://robot.test/obzvon/sostoyanie');
|
||||
Http::assertSent(fn (Request $z) => $z->url() === 'https://robot.test/obzvon/ostanovit');
|
||||
});
|
||||
|
||||
it('состояние разговора возвращает то, что сказал робот', function () {
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'rob-42', 'sostoyanie' => 'govorit'], 200));
|
||||
|
||||
$otvet = klientObzvona()->sostoyanie('rob-42');
|
||||
|
||||
expect($otvet->poluchenLi())->toBeTrue()
|
||||
->and($otvet->telo['sostoyanie'])->toBe('govorit');
|
||||
});
|
||||
|
||||
it('состояние и остановка без номера звонка — наружу не ходим', function () {
|
||||
Http::fake();
|
||||
$klient = klientObzvona();
|
||||
|
||||
expect($klient->sostoyanie('')->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
|
||||
->and($klient->ostanovit(' ')->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('🪤 «остановлено» роботом не доказано, пока он молчит', function () {
|
||||
Http::fake(fn () => throw new ConnectionException('оборвано'));
|
||||
|
||||
// Тишина ответа не равна тишине в трубке: разговор может идти дальше.
|
||||
expect(klientObzvona()->ostanovit('rob-42')->iskhod)->toBe(IskhodObrashcheniya::Neizvestno);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Настройки
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('клиент из настроек берёт адрес, ключ и пределы', function () {
|
||||
config()->set('services.obzvon.endpoint', 'https://robot.test/obzvon');
|
||||
config()->set('services.obzvon.key', 'iz-nastroek');
|
||||
config()->set('services.obzvon.concurrency', 2);
|
||||
config()->set('services.obzvon.wait_seconds', 0);
|
||||
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
|
||||
|
||||
expect(ObzvonClient::izNastroek()->pozvonit(...zadanieProbnoe())->poluchenLi())->toBeTrue();
|
||||
|
||||
Http::assertSent(fn (Request $z) => $z->hasHeader('X-Obzvon-Key', 'iz-nastroek'));
|
||||
});
|
||||
|
||||
it('🔴 в настройках нет ни одного секрета в открытом виде', function () {
|
||||
$ishodnik = (string) file_get_contents(__DIR__.'/../../../config/services.php');
|
||||
$blok = mb_substr($ishodnik, (int) mb_strpos($ishodnik, "'obzvon' => ["));
|
||||
|
||||
// Всё, что секрет, обязано приходить только из окружения.
|
||||
expect($blok)->toContain("env('OBZVON_KEY'")
|
||||
->and($blok)->toContain("env('OBZVON_ENDPOINT'");
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ClientSmsSettings;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Services\ClientSms\LocalHoursWindow;
|
||||
use App\Services\Obzvon\ObzvonOknoChasov;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Обёртка обзвона над правилом часов — задача З-3.1.
|
||||
*
|
||||
* Решения владельца: Р76 (правило одно, живой СМС-модуль правим; в общее
|
||||
* пользование отдано ПРАВИЛО, а не ЧИСЛА), Р64 (окно клиента только сужает),
|
||||
* Р115 (два окна в разных часах, рамка — пересечение), Р35 (входящие окном не
|
||||
* ограничены).
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
$MSK = 3 * 60;
|
||||
$VLADIVOSTOK = 10 * 60;
|
||||
|
||||
it('без своей настройки держит общую рамку 10:00–20:00', function () use ($MSK) {
|
||||
$okno = new ObzvonOknoChasov;
|
||||
|
||||
expect($okno->otChasa())->toBe(10);
|
||||
expect($okno->doChasa())->toBe(20);
|
||||
|
||||
expect($okno->mozhnoNabirat($MSK, null, CarbonImmutable::parse('2026-08-03 06:59', 'UTC')))->toBeFalse(); // 09:59 МСК
|
||||
expect($okno->mozhnoNabirat($MSK, null, CarbonImmutable::parse('2026-08-03 07:00', 'UTC')))->toBeTrue(); // 10:00 МСК
|
||||
expect($okno->mozhnoNabirat($MSK, null, CarbonImmutable::parse('2026-08-03 16:59', 'UTC')))->toBeTrue(); // 19:59 МСК
|
||||
expect($okno->mozhnoNabirat($MSK, null, CarbonImmutable::parse('2026-08-03 17:00', 'UTC')))->toBeFalse(); // 20:00 МСК
|
||||
});
|
||||
|
||||
it('🔴 админ поправил окно РАССЫЛОК — окно обзвона не сдвинулось', function () use ($MSK) {
|
||||
// Р76 отдал в общее пользование правило, а не числа. Прочитавший его как
|
||||
// «одни часы на двоих» сломает вот это.
|
||||
ClientSmsSettings::query()->update(['quiet_from_hour' => 12, 'quiet_to_hour' => 14]);
|
||||
|
||||
$okno = new ObzvonOknoChasov;
|
||||
|
||||
expect($okno->otChasa())->toBe(10);
|
||||
expect($okno->doChasa())->toBe(20);
|
||||
|
||||
// 07:30 UTC = 10:30 МСК: для рассылок теперь рано, для обзвона — можно.
|
||||
expect($okno->mozhnoNabirat($MSK, null, CarbonImmutable::parse('2026-08-03 07:30', 'UTC')))->toBeTrue();
|
||||
});
|
||||
|
||||
it('свою настройку слушается, и чужую при этом не трогает', function () use ($MSK) {
|
||||
SystemSetting::query()->insert([
|
||||
['key' => ObzvonOknoChasov::KLYUCH_OT_CHASA, 'value' => '11', 'type' => 'int', 'updated_at' => now()],
|
||||
['key' => ObzvonOknoChasov::KLYUCH_DO_CHASA, 'value' => '17', 'type' => 'int', 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$okno = new ObzvonOknoChasov;
|
||||
|
||||
expect($okno->otChasa())->toBe(11);
|
||||
expect($okno->doChasa())->toBe(17);
|
||||
|
||||
expect($okno->mozhnoNabirat($MSK, null, CarbonImmutable::parse('2026-08-03 07:30', 'UTC')))->toBeFalse(); // 10:30 МСК
|
||||
expect($okno->mozhnoNabirat($MSK, null, CarbonImmutable::parse('2026-08-03 08:30', 'UTC')))->toBeTrue(); // 11:30 МСК
|
||||
|
||||
// Настройки рассылок остались нетронутыми.
|
||||
expect(ClientSmsSettings::first()->quiet_from_hour)->toBe(10);
|
||||
expect(ClientSmsSettings::first()->quiet_to_hour)->toBe(20);
|
||||
});
|
||||
|
||||
it('опечатка в настройке не превращается в круглосуточный обзвон', function () {
|
||||
SystemSetting::query()->insert([
|
||||
['key' => ObzvonOknoChasov::KLYUCH_OT_CHASA, 'value' => '', 'type' => 'int', 'updated_at' => now()],
|
||||
['key' => ObzvonOknoChasov::KLYUCH_DO_CHASA, 'value' => 'вечером', 'type' => 'int', 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$okno = new ObzvonOknoChasov;
|
||||
|
||||
expect($okno->otChasa())->toBe(10);
|
||||
expect($okno->doChasa())->toBe(20);
|
||||
});
|
||||
|
||||
it('вывернутое окно «с 20 до 10» из настройки заменяется рамкой, а не принимается', function () {
|
||||
SystemSetting::query()->insert([
|
||||
['key' => ObzvonOknoChasov::KLYUCH_OT_CHASA, 'value' => '20', 'type' => 'int', 'updated_at' => now()],
|
||||
['key' => ObzvonOknoChasov::KLYUCH_DO_CHASA, 'value' => '10', 'type' => 'int', 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$okno = new ObzvonOknoChasov;
|
||||
|
||||
expect($okno->otChasa())->toBe(10);
|
||||
expect($okno->doChasa())->toBe(20);
|
||||
});
|
||||
|
||||
it('🔴 ЛОВУШКА: менеджер в Москве 10:00–18:00, получатель во Владивостоке', function () use ($MSK, $VLADIVOSTOK) {
|
||||
$okno = new ObzvonOknoChasov;
|
||||
$menedzher = new LocalHoursWindow($MSK, 10, 18);
|
||||
|
||||
// 04:30 UTC = 14:30 по ВЛАДИВОСТОКУ. Сузив одно окно вместо пересечения двух,
|
||||
// портал набрал бы номер здесь — а в Москве 07:30, менеджер спит.
|
||||
expect($okno->mozhnoNabirat($VLADIVOSTOK, $menedzher, CarbonImmutable::parse('2026-08-03 04:30', 'UTC')))->toBeFalse();
|
||||
|
||||
// 07:30 UTC = 10:30 МСК и 17:30 Владивосток — открыты оба.
|
||||
expect($okno->mozhnoNabirat($VLADIVOSTOK, $menedzher, CarbonImmutable::parse('2026-08-03 07:30', 'UTC')))->toBeTrue();
|
||||
|
||||
// 10:30 UTC = 13:30 МСК (менеджер на месте), но 20:30 Владивосток — рамка закрыта.
|
||||
expect($okno->mozhnoNabirat($VLADIVOSTOK, $menedzher, CarbonImmutable::parse('2026-08-03 10:30', 'UTC')))->toBeFalse();
|
||||
});
|
||||
|
||||
it('часы клиента шире рамки принимаются как рамка и показываются человеку', function () use ($MSK) {
|
||||
$okno = new ObzvonOknoChasov;
|
||||
|
||||
expect($okno->oknoMenedzheraPosleSuzheniya(new LocalHoursWindow($MSK, 9, 22))->podpis())->toBe('10:00–20:00');
|
||||
expect($okno->oknoMenedzheraPosleSuzheniya(new LocalHoursWindow($MSK, 11, 17))->podpis())->toBe('11:00–17:00');
|
||||
expect($okno->oknoMenedzheraPosleSuzheniya(new LocalHoursWindow($MSK, 20, 23)))->toBeNull();
|
||||
});
|
||||
|
||||
it('когда общего окна нет вовсе — отвечает пустотой, а не «прямо сейчас»', function () use ($MSK) {
|
||||
$okno = new ObzvonOknoChasov;
|
||||
$kamchatka = 12 * 60;
|
||||
|
||||
// Москва 14:00–18:00 = 11:00–15:00 UTC; Камчатка 10:00–20:00 = 22:00–08:00 UTC.
|
||||
expect($okno->kogdaOtkroetsya($kamchatka, new LocalHoursWindow($MSK, 14, 18), CarbonImmutable::parse('2026-08-03 09:00', 'UTC')))
|
||||
->toBeNull();
|
||||
|
||||
// А без часов менеджера ответ есть всегда — пустоты быть не может.
|
||||
expect($okno->kogdaOtkroetsya($kamchatka, null, CarbonImmutable::parse('2026-08-03 09:00', 'UTC')))
|
||||
->not->toBeNull();
|
||||
});
|
||||
|
||||
it('🔴 Р35: спросить обзвонное окно про ВХОДЯЩИЙ звонок физически нечем', function () {
|
||||
// Входящие принимаются круглосуточно. Окно задаёт часы НАШЕГО набора.
|
||||
// Сторож краснеет, если кто-нибудь заведёт здесь метод про приём звонка.
|
||||
$metody = get_class_methods(ObzvonOknoChasov::class);
|
||||
sort($metody);
|
||||
|
||||
expect($metody)->toBe([
|
||||
'doChasa',
|
||||
'kogdaOtkroetsya',
|
||||
'mozhnoNabirat',
|
||||
'oknoMenedzheraPosleSuzheniya',
|
||||
'otChasa',
|
||||
]);
|
||||
});
|
||||
|
||||
it('🔴 обёртка не считает часы сама — второго дома у правила нет', function () {
|
||||
// Р76: скопированное сюда сравнение часов и есть тот второй дом, который
|
||||
// запрещён. Сторож краснеет, если он появится.
|
||||
$tekst = file_get_contents(base_path('app/Services/Obzvon/ObzvonOknoChasov.php'));
|
||||
|
||||
// Тело класса без шапки-докблока: в шапке ловушка описана словами.
|
||||
$telo = substr($tekst, strpos($tekst, 'final class'));
|
||||
|
||||
expect($telo)->not->toContain('addMinutes');
|
||||
expect($telo)->not->toContain('startOfDay');
|
||||
expect($telo)->not->toContain("format('G')");
|
||||
expect($telo)->not->toContain('CarbonImmutable::now');
|
||||
});
|
||||
|
||||
it('🔴 на большом списке номеров запрос к базе ровно один', function () use ($MSK) {
|
||||
// Беду «вопрос к базе внутри перебора номеров» в этом проекте ловили дважды
|
||||
// за одну неделю: отбор 20 000 номеров вырос с 20 до 29 секунд именно так.
|
||||
$okno = new ObzvonOknoChasov;
|
||||
$menedzher = new LocalHoursWindow($MSK, 11, 17);
|
||||
$mig = CarbonImmutable::parse('2026-08-03 09:00', 'UTC');
|
||||
|
||||
$zaprosov = 0;
|
||||
DB::listen(function () use (&$zaprosov): void {
|
||||
$zaprosov++;
|
||||
});
|
||||
|
||||
for ($i = 0; $i < 20000; $i++) {
|
||||
$okno->mozhnoNabirat(($i % 11 + 2) * 60, $menedzher, $mig);
|
||||
}
|
||||
|
||||
expect($zaprosov)->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,579 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* З-2.4 — «голый шов»: запись исхода МИМО двери.
|
||||
*
|
||||
* 🔴 ОТ КАКОГО ВРЕДА. Правило Т84 «недозвон не затирает состоявшийся разговор»
|
||||
* живёт в двери `ItogPoNomeru`. Восемь сторожей доказывают, что дверь работает.
|
||||
* Но все восемь ходят ЧЕРЕЗ дверь, а дверью можно не воспользоваться — и тогда
|
||||
* они все останутся зелёными, пока разговор затирается молча.
|
||||
*
|
||||
* Это не выдуманная опасность, а самая вероятная из всех: итог по номеру
|
||||
* сегодня не пишет НИКТО, значит первым пишущим будет следующая смена,
|
||||
* строящая приём отчёта робота. Она увидит у модели поле `outcome` раньше, чем
|
||||
* найдёт дверь, напишет в него напрямую — и Т84 рухнет, а дверь останется
|
||||
* целой и никем не использованной.
|
||||
*
|
||||
* 🔴 ЗАЩИТА РАЗДЕЛЕНА ПО УСТРОЙСТВУ, А НЕ ПО СПИСКУ ИМЁН. Белого списка «кому
|
||||
* можно писать» здесь нет нигде — он сам стал бы дырой: сегодня в нём одна
|
||||
* дверь, завтра допишут вторую.
|
||||
*
|
||||
* 1. **Пол в самой модели** (`ObzvonNumberResult::booted`) ловит ЛЮБУЮ запись
|
||||
* через объект — присваивание полю, `fill`, `update` у строки, `save` —
|
||||
* независимо от того, как назвали переменную и где собрали массив. Автор
|
||||
* записи не проверяется вовсе, проверяется СМЫСЛ перехода;
|
||||
* 2. **проверка ниже** ловит то, чего пол не видит физически: массовую правку
|
||||
* и запросы построителем — на них Eloquent событий не шлёт.
|
||||
*
|
||||
* 🪤 ЧЕГО НЕ ЛОВИТ НИ ОДИН ИЗ ДВУХ, называю вслух, потому что это важнее того,
|
||||
* что они ловят:
|
||||
* • правку прямым SQL мимо портала — из `psql`, из миграции, из чужой службы.
|
||||
* От неё спасёт только замок в самой базе. Это правка канона схемы и
|
||||
* решение владельца, поэтому названо, а не сделано молча;
|
||||
* • имя таблицы, собранное из кусков в переменную: `$t = 'obzvon_'.'number_results'`;
|
||||
* • столбец, собранный из кусков: `->update([$pole => 'no_answer'])`;
|
||||
* • правку из кода вне `app/app` — например из отдельного скрипта в `tools/`.
|
||||
* Первое лечится только базой, остальные три — недостижимы для чтения текста
|
||||
* в принципе, и честнее это назвать, чем делать вид, что сторож полон.
|
||||
*/
|
||||
|
||||
use App\Models\ObzvonNumberResult;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Obzvon\ItogPoNomeru;
|
||||
use App\Services\Obzvon\RaskladkaIskhodov;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. ПОЛ В МОДЕЛИ — ловит запись через объект, кто бы её ни делал
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('🔴 прямое присваивание полю мимо двери НЕ проходит', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$itog = ObzvonNumberResult::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'campaign_id' => 1,
|
||||
'phone' => '79001110001',
|
||||
]);
|
||||
(new ItogPoNomeru)->zapisat($itog, ObzvonNumberResult::OUTCOME_TRANSFERRED, Carbon::parse('2026-08-06 10:00:00'));
|
||||
|
||||
// Ровно то, что напишет следующая смена, не найдя двери.
|
||||
$itog->outcome = ObzvonNumberResult::OUTCOME_NO_ANSWER;
|
||||
$itog->outcome_at = Carbon::parse('2026-08-06 18:00:00');
|
||||
|
||||
expect(fn () => $itog->save())->toThrow(RuntimeException::class, 'Т84');
|
||||
|
||||
expect(DB::table('obzvon_number_results')->where('id', $itog->id)->value('outcome'))
|
||||
->toBe(ObzvonNumberResult::OUTCOME_TRANSFERRED);
|
||||
});
|
||||
|
||||
test('🔴 update по строке мимо двери НЕ проходит', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$itog = ObzvonNumberResult::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'campaign_id' => 1,
|
||||
'phone' => '79001110002',
|
||||
]);
|
||||
(new ItogPoNomeru)->zapisat($itog, ObzvonNumberResult::OUTCOME_CALLBACK, Carbon::parse('2026-08-06 10:00:00'));
|
||||
|
||||
expect(fn () => $itog->update(['outcome' => ObzvonNumberResult::OUTCOME_NO_ANSWER]))
|
||||
->toThrow(RuntimeException::class);
|
||||
|
||||
expect(DB::table('obzvon_number_results')->where('id', $itog->id)->value('outcome'))
|
||||
->toBe(ObzvonNumberResult::OUTCOME_CALLBACK);
|
||||
});
|
||||
|
||||
test('🔴 массовая правка мимо двери НЕ проходит — включая запись через связь', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$itog = ObzvonNumberResult::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'campaign_id' => 1,
|
||||
'phone' => '79001110010',
|
||||
]);
|
||||
(new ItogPoNomeru)->zapisat($itog, ObzvonNumberResult::OUTCOME_TRANSFERRED, Carbon::parse('2026-08-06 10:00:00'));
|
||||
|
||||
// 1. Массовая правка через модель.
|
||||
expect(fn () => ObzvonNumberResult::query()->whereKey($itog->id)
|
||||
->update(['outcome' => ObzvonNumberResult::OUTCOME_NO_ANSWER]))
|
||||
->toThrow(RuntimeException::class, 'Т84');
|
||||
|
||||
// 2. 🔴 Через СВЯЗЬ — ножевой случай надзирателя. Связи `Deal → итоги` в
|
||||
// портале ещё нет, поэтому берём ту же дорогу, какой пойдёт она:
|
||||
// построитель, полученный от модели, с внешним условием.
|
||||
$svyaz = $itog->newQuery()->where('tenant_id', $tenant->id);
|
||||
expect(fn () => $svyaz->update(['outcome' => ObzvonNumberResult::OUTCOME_NO_ANSWER]))
|
||||
->toThrow(RuntimeException::class, 'Т84');
|
||||
|
||||
expect(DB::table('obzvon_number_results')->where('id', $itog->id)->value('outcome'))
|
||||
->toBe(ObzvonNumberResult::OUTCOME_TRANSFERRED);
|
||||
});
|
||||
|
||||
test('массовая правка пропускает то, что обязана пропускать', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$pustoy = ObzvonNumberResult::create(['tenant_id' => $tenant->id, 'campaign_id' => 1, 'phone' => '79001110011']);
|
||||
|
||||
// 1. Недозвон поверх пустоты — двенадцать попыток обязаны дать итог.
|
||||
ObzvonNumberResult::query()->whereKey($pustoy->id)
|
||||
->update(['outcome' => ObzvonNumberResult::OUTCOME_NO_ANSWER]);
|
||||
expect($pustoy->fresh()->outcome)->toBe(ObzvonNumberResult::OUTCOME_NO_ANSWER);
|
||||
|
||||
// 2. Разговор поверх недозвона — разрешено.
|
||||
ObzvonNumberResult::query()->whereKey($pustoy->id)
|
||||
->update(['outcome' => ObzvonNumberResult::OUTCOME_REFUSED]);
|
||||
expect($pustoy->fresh()->outcome)->toBe(ObzvonNumberResult::OUTCOME_REFUSED);
|
||||
|
||||
// 3. Очистка — право на обезличивание сильнее Т84.
|
||||
ObzvonNumberResult::query()->whereKey($pustoy->id)->update(['outcome' => null]);
|
||||
expect($pustoy->fresh()->outcome)->toBeNull();
|
||||
|
||||
// 4. Правка соседнего поля не задевает исход.
|
||||
ObzvonNumberResult::query()->whereKey($pustoy->id)->update(['attempts_count' => 7]);
|
||||
expect($pustoy->fresh()->attempts_count)->toBe(7);
|
||||
});
|
||||
|
||||
test('пол пропускает то, что обязан пропускать', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
// 1. Первая запись поверх пустоты — двенадцать недозвонов обязаны дать итог.
|
||||
$pervyy = ObzvonNumberResult::create(['tenant_id' => $tenant->id, 'campaign_id' => 1, 'phone' => '79001110003']);
|
||||
$pervyy->outcome = ObzvonNumberResult::OUTCOME_NO_ANSWER;
|
||||
$pervyy->save();
|
||||
expect($pervyy->fresh()->outcome)->toBe(ObzvonNumberResult::OUTCOME_NO_ANSWER);
|
||||
|
||||
// 2. Недозвон поверх недозвона — обычный ход.
|
||||
$pervyy->outcome_at = Carbon::parse('2026-08-06 12:00:00');
|
||||
$pervyy->outcome = ObzvonNumberResult::OUTCOME_NO_ANSWER;
|
||||
$pervyy->save();
|
||||
|
||||
// 3. Разговор поверх недозвона — разрешено и обязано пройти.
|
||||
$pervyy->outcome = ObzvonNumberResult::OUTCOME_REFUSED;
|
||||
$pervyy->save();
|
||||
expect($pervyy->fresh()->outcome)->toBe(ObzvonNumberResult::OUTCOME_REFUSED);
|
||||
|
||||
// 4. Очистка исхода — право на обезличивание сильнее Т84.
|
||||
$pervyy->outcome = null;
|
||||
$pervyy->save();
|
||||
expect($pervyy->fresh()->outcome)->toBeNull();
|
||||
|
||||
// 5. Правка соседнего поля не трогает исход и не должна падать.
|
||||
$vtoroy = ObzvonNumberResult::create(['tenant_id' => $tenant->id, 'campaign_id' => 1, 'phone' => '79001110004']);
|
||||
(new ItogPoNomeru)->zapisat($vtoroy, ObzvonNumberResult::OUTCOME_TRANSFERRED);
|
||||
$vtoroy->update(['attempts_count' => 5]);
|
||||
expect($vtoroy->fresh()->attempts_count)->toBe(5);
|
||||
});
|
||||
|
||||
test('дверь пола не задевает — она никогда не делает запрещённого перехода', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$itog = ObzvonNumberResult::create(['tenant_id' => $tenant->id, 'campaign_id' => 1, 'phone' => '79001110005']);
|
||||
$dver = new ItogPoNomeru;
|
||||
|
||||
$dver->zapisat($itog, ObzvonNumberResult::OUTCOME_TRANSFERRED, Carbon::parse('2026-08-06 10:00:00'));
|
||||
|
||||
// Дверь обязана ОТБИТЬ недозвон, а не упасть: отбитие это штатный ход.
|
||||
expect($dver->zapisat($itog, ObzvonNumberResult::OUTCOME_NO_ANSWER, Carbon::parse('2026-08-06 18:00:00')))
|
||||
->toBeFalse();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. СВЯЗЬ ПОЛА И ДВЕРИ — два места применения правила не должны разъехаться
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('пол и дверь отвечают одинаково на всех 64 парах исходов', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$nomer = 0;
|
||||
foreach (ObzvonNumberResult::OUTCOMES as $staryy) {
|
||||
foreach (ObzvonNumberResult::OUTCOMES as $novyy) {
|
||||
$nomer++;
|
||||
$itog = ObzvonNumberResult::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'campaign_id' => 900,
|
||||
'phone' => '7901'.str_pad((string) $nomer, 7, '0', STR_PAD_LEFT),
|
||||
]);
|
||||
|
||||
// Ставим прежний исход в обход двери — через базу, чтобы пол не
|
||||
// мешал готовить заготовку.
|
||||
DB::table('obzvon_number_results')->where('id', $itog->id)
|
||||
->update(['outcome' => $staryy, 'outcome_at' => now()]);
|
||||
$itog->refresh();
|
||||
|
||||
$dverPropustit = RaskladkaIskhodov::perebivaet($staryy, $novyy);
|
||||
|
||||
$itog->outcome = $novyy;
|
||||
$polUpal = false;
|
||||
try {
|
||||
$itog->save();
|
||||
} catch (RuntimeException) {
|
||||
$polUpal = true;
|
||||
}
|
||||
|
||||
// Пол падает РОВНО на запрещённом Т84 переходе — и ровно тогда же
|
||||
// дверь отвечает «не перебивает» по правилу 1.
|
||||
$zapreshcheno = RaskladkaIskhodov::proPopytku($novyy) && RaskladkaIskhodov::proNomer($staryy);
|
||||
|
||||
expect($polUpal)->toBe(
|
||||
$zapreshcheno,
|
||||
"пара {$staryy} → {$novyy}: пол ответил не так, как требует Т84"
|
||||
);
|
||||
|
||||
if ($zapreshcheno) {
|
||||
expect($dverPropustit)->toBeFalse("пара {$staryy} → {$novyy}: дверь разошлась с полом");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. ЧТО ПОЛУ НЕ ВИДНО — массовая правка и построитель запросов
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('🔴 в боевом коде нет ни одной записи исхода массовой правкой или построителем', function () {
|
||||
$najdeno = mimoDveriNajti(base_path('app'));
|
||||
|
||||
expect($najdeno)->toBe(
|
||||
[],
|
||||
"Запись исхода мимо двери и мимо пола:\n".implode("\n", $najdeno)
|
||||
."\nПисать исход можно только через App\\Services\\Obzvon\\ItogPoNomeru::zapisat."
|
||||
);
|
||||
});
|
||||
|
||||
test('сторож мимо-двери видит подсунутую ему запись — доказано, а не обещано', function () {
|
||||
// 🔴 Сторож, которого не видели красным, не сторожит ничего. Здесь он
|
||||
// краснеет на подкладке НЕ вырезанием лечения, а подсунутой уликой: так
|
||||
// проверяется сам прибор, а не то, что вокруг.
|
||||
$vremennaya = base_path('storage/framework/testing/mimo-dveri-proba');
|
||||
@mkdir($vremennaya, 0777, true);
|
||||
$fayl = $vremennaya.'/PodstavnoyPisatel.php';
|
||||
|
||||
file_put_contents($fayl, <<<'PHP'
|
||||
<?php
|
||||
|
||||
class PodstavnoyPisatel
|
||||
{
|
||||
public function pisat(): void
|
||||
{
|
||||
DB::table('obzvon_number_results')
|
||||
->where('id', 1)
|
||||
->update(['outcome' => 'no_answer']);
|
||||
}
|
||||
}
|
||||
PHP);
|
||||
|
||||
try {
|
||||
$najdeno = mimoDveriNajti($vremennaya);
|
||||
|
||||
expect($najdeno)->toHaveCount(1)
|
||||
->and($najdeno[0])->toContain('PodstavnoyPisatel.php')
|
||||
// Имя файла мало — сторож обязан назвать СТРОКУ.
|
||||
->and($najdeno[0])->toMatch('/:\d+/');
|
||||
} finally {
|
||||
@unlink($fayl);
|
||||
@rmdir($vremennaya);
|
||||
}
|
||||
});
|
||||
|
||||
test('🔴 запись через СВЯЗЬ — ни имени класса, ни имени таблицы — сторож ловит', function () {
|
||||
// 🔴 Нож надзирателя 06.08.2026. До третьего якоря этот случай проходил
|
||||
// мимо ОБЕИХ половин: пол молчал, потому что это построитель, а не объект;
|
||||
// обход не начинался, потому что искал имена, которых в выражении нет.
|
||||
$vremennaya = base_path('storage/framework/testing/mimo-dveri-svyaz');
|
||||
@mkdir($vremennaya, 0777, true);
|
||||
$fayl = $vremennaya.'/PisatelCherezSvyaz.php';
|
||||
|
||||
file_put_contents($fayl, <<<'PHP'
|
||||
<?php
|
||||
|
||||
class PisatelCherezSvyaz
|
||||
{
|
||||
public function pisat($sdelka): void
|
||||
{
|
||||
$sdelka->obzvonItogi()->update(['outcome' => 'no_answer']);
|
||||
}
|
||||
}
|
||||
PHP);
|
||||
|
||||
try {
|
||||
$najdeno = mimoDveriNajti($vremennaya);
|
||||
|
||||
expect($najdeno)->toHaveCount(1)
|
||||
->and($najdeno[0])->toContain('PisatelCherezSvyaz.php')
|
||||
->and($najdeno[0])->toMatch('/:\d+/')
|
||||
->and($najdeno[0])->toContain('obzvonItogi');
|
||||
} finally {
|
||||
@unlink($fayl);
|
||||
@rmdir($vremennaya);
|
||||
}
|
||||
});
|
||||
|
||||
test('🔴 пояснение в шапке двери НЕ поднимает ложной тревоги', function () {
|
||||
// Ложная краснота хуже дыры: она приучает не смотреть. В шапке
|
||||
// `ItogPoNomeru` дословно написано `$itog->update(['outcome' => ...])` —
|
||||
// как образец запрещённого. Обход обязан читать это как комментарий.
|
||||
$vremennaya = base_path('storage/framework/testing/mimo-dveri-kommentariy');
|
||||
@mkdir($vremennaya, 0777, true);
|
||||
$fayl = $vremennaya.'/PisatelSPoyasneniem.php';
|
||||
|
||||
file_put_contents($fayl, <<<'PHP'
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Зачем дверь, а не просто $itog->update(['outcome' => 'no_answer']).
|
||||
*/
|
||||
class PisatelSPoyasneniem
|
||||
{
|
||||
// И строчный тоже: ->update(['outcome' => 'refused'])
|
||||
public function nichegoNePishet(): string
|
||||
{
|
||||
return 'здесь записи нет';
|
||||
}
|
||||
}
|
||||
PHP);
|
||||
|
||||
try {
|
||||
expect(mimoDveriNajti($vremennaya))->toBe([]);
|
||||
} finally {
|
||||
@unlink($fayl);
|
||||
@rmdir($vremennaya);
|
||||
}
|
||||
});
|
||||
|
||||
test('🔴 правка СОСЕДНЕЙ таблицы по attempt_outcome не даёт ложной тревоги', function () {
|
||||
// Замер, на котором стоит третий якорь: столбец ровно `outcome` есть во
|
||||
// всём каноне только у `obzvon_number_results`, а у `obzvon_calls` он
|
||||
// зовётся `attempt_outcome`. Подстрока без кавычек совпала бы и здесь.
|
||||
$vremennaya = base_path('storage/framework/testing/mimo-dveri-sosed');
|
||||
@mkdir($vremennaya, 0777, true);
|
||||
$fayl = $vremennaya.'/PisatelSosedney.php';
|
||||
|
||||
file_put_contents($fayl, <<<'PHP'
|
||||
<?php
|
||||
|
||||
class PisatelSosedney
|
||||
{
|
||||
public function pisat($zvonki): void
|
||||
{
|
||||
$zvonki->update(['attempt_outcome' => 'no_answer']);
|
||||
}
|
||||
}
|
||||
PHP);
|
||||
|
||||
try {
|
||||
expect(mimoDveriNajti($vremennaya))->toBe([]);
|
||||
} finally {
|
||||
@unlink($fayl);
|
||||
@rmdir($vremennaya);
|
||||
}
|
||||
});
|
||||
|
||||
test('сторож мимо-двери не поднимает ложной тревоги на честном коде', function () {
|
||||
// Обезличивание правит `phone` массово и обязано проходить: сторож смотрит
|
||||
// на исход, а не на любую правку таблицы.
|
||||
$vremennaya = base_path('storage/framework/testing/mimo-dveri-chestnyy');
|
||||
@mkdir($vremennaya, 0777, true);
|
||||
$fayl = $vremennaya.'/ChestnyyPisatel.php';
|
||||
|
||||
file_put_contents($fayl, <<<'PHP'
|
||||
<?php
|
||||
|
||||
class ChestnyyPisatel
|
||||
{
|
||||
public function obezlichit(): void
|
||||
{
|
||||
DB::table('obzvon_number_results')
|
||||
->where('id', 1)
|
||||
->update(['phone' => '+70000000000']);
|
||||
}
|
||||
}
|
||||
PHP);
|
||||
|
||||
try {
|
||||
expect(mimoDveriNajti($vremennaya))->toBe([]);
|
||||
} finally {
|
||||
@unlink($fayl);
|
||||
@rmdir($vremennaya);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Обход кода в поисках записи исхода мимо объекта модели.
|
||||
*
|
||||
* 🔴 Белого списка «кому можно» здесь НЕТ и быть не должно. Дверь пишет через
|
||||
* объект модели, а не массовой правкой, поэтому запрет действует на ВСЕХ без
|
||||
* исключений — включая саму дверь. Список разрешённых имён сам стал бы дырой.
|
||||
*
|
||||
* @return list<string> найденные места в виде «файл:строка — кусок»
|
||||
*/
|
||||
function mimoDveriNajti(string $koren): array
|
||||
{
|
||||
if (! is_dir($koren)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$najdeno = [];
|
||||
$obhod = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($koren, FilesystemIterator::SKIP_DOTS));
|
||||
|
||||
/** @var SplFileInfo $fayl */
|
||||
foreach ($obhod as $fayl) {
|
||||
if ($fayl->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tekst = mimoDveriBezKommentariev((string) file_get_contents($fayl->getPathname()));
|
||||
|
||||
// Файл самой модели и её построителя пропускаем: в них поле `outcome`
|
||||
// упоминается по делу — там стоят полы. Это НЕ разрешение писать, а
|
||||
// отказ читать объявление столбца как его запись.
|
||||
if (str_contains($tekst, 'class ObzvonNumberResult extends Model')
|
||||
|| str_contains($tekst, 'class ObzvonNumberResultBuilder extends Builder')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (mimoDveriUlikiVTekste($tekst) as [$stroka, $kusok]) {
|
||||
$najdeno[] = $fayl->getPathname().':'.$stroka.' — '.$kusok;
|
||||
}
|
||||
}
|
||||
|
||||
sort($najdeno);
|
||||
|
||||
return $najdeno;
|
||||
}
|
||||
|
||||
/**
|
||||
* Убрать из кода комментарии, оставив строки и сам код на своих местах.
|
||||
*
|
||||
* 🔴 ЗАЧЕМ. В шапке двери `ItogPoNomeru` стоит пояснение, которое ДОСЛОВНО
|
||||
* содержит `$itog->update(['outcome' => ...])` — как образец того, чего делать
|
||||
* нельзя. Обход, читающий текст как есть, поднял бы на этом ложную тревогу и
|
||||
* покраснел бы на самой двери. Ложная краснота хуже дыры: она приучает не
|
||||
* смотреть.
|
||||
*
|
||||
* Комментарии убираются разбором PHP, а не вычёркиванием по образцу: разборщик
|
||||
* отличает комментарий от такой же строки внутри кавычек, а образец — нет.
|
||||
* Длина текста сохраняется — пробелами вместо вырезанного, — иначе номера строк
|
||||
* в отчёте сторожа поехали бы.
|
||||
*/
|
||||
function mimoDveriBezKommentariev(string $tekst): string
|
||||
{
|
||||
$chisto = '';
|
||||
|
||||
foreach (token_get_all($tekst) as $token) {
|
||||
if (is_array($token)) {
|
||||
if ($token[0] === T_COMMENT || $token[0] === T_DOC_COMMENT) {
|
||||
// Переводы строк оставляем, всё прочее заменяем пробелом.
|
||||
$chisto .= preg_replace('/[^\n]/', ' ', $token[1]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$chisto .= $token[1];
|
||||
|
||||
continue;
|
||||
}
|
||||
$chisto .= $token;
|
||||
}
|
||||
|
||||
return $chisto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Улики внутри одного файла: выражение, которое ОДНОВРЕМЕННО пишет и относится
|
||||
* к таблице итогов — по имени таблицы, по имени класса либо по самому столбцу.
|
||||
*
|
||||
* @return list<array{0: int, 1: string}>
|
||||
*/
|
||||
function mimoDveriUlikiVTekste(string $tekst): array
|
||||
{
|
||||
$uliki = [];
|
||||
|
||||
// Три якоря, а не два.
|
||||
//
|
||||
// 🔴 Третий добавлен ножом надзирателя 06.08.2026. Запись через СВЯЗЬ —
|
||||
// `$sdelka->obzvonItogi()->update(['outcome' => ...])` — не содержит ни
|
||||
// имени класса, ни имени таблицы, и первые два якоря её не находили вовсе.
|
||||
//
|
||||
// Якорем служит сам столбец, и это безопасно, потому что ЗАМЕРЕНО:
|
||||
// столбец ровно `outcome` есть во всём каноне схемы РОВНО У ОДНОЙ таблицы —
|
||||
// `db/schema_modules.sql:1351`, `obzvon_number_results`. В миграциях его нет
|
||||
// вовсе. У соседней `obzvon_calls` столбец зовётся `attempt_outcome`.
|
||||
//
|
||||
// 🪤 Ключ берётся В КАВЫЧКАХ, и это не украшение: подстрока `outcome` без
|
||||
// кавычек совпала бы с `'attempt_outcome'` и дала бы ложную красноту на
|
||||
// правках соседней таблицы. Ложная краснота хуже дыры — она приучает не
|
||||
// смотреть.
|
||||
$nachala = [];
|
||||
foreach (['obzvon_number_results', 'ObzvonNumberResult', "'outcome'", '"outcome"'] as $imya) {
|
||||
$smeshchenie = 0;
|
||||
while (($poziciya = strpos($tekst, $imya, $smeshchenie)) !== false) {
|
||||
$nachala[] = $poziciya;
|
||||
$smeshchenie = $poziciya + 1;
|
||||
}
|
||||
}
|
||||
sort($nachala);
|
||||
|
||||
$vidennye = [];
|
||||
|
||||
foreach ($nachala as $nachalo) {
|
||||
// 🔴 Выражение берётся ЦЕЛИКОМ — от конца предыдущего до конца этого,
|
||||
// а не «вперёд от якоря». Ключ `'outcome'` стоит ВНУТРИ вызова, то есть
|
||||
// ПОСЛЕ `->update(`, и поиск только вперёд его бы не связал с записью.
|
||||
// Цепочка `->where(...)->update([...])` вдобавок переносится на
|
||||
// несколько строк, и построчный поиск не увидел бы её тоже.
|
||||
$granica = 0;
|
||||
foreach ([';', '{', '}'] as $znak) {
|
||||
$poziciya = strrpos(substr($tekst, 0, $nachalo), $znak);
|
||||
if ($poziciya !== false && $poziciya + 1 > $granica) {
|
||||
$granica = $poziciya + 1;
|
||||
}
|
||||
}
|
||||
|
||||
$konec = strpos($tekst, ';', $nachalo);
|
||||
$konec = $konec === false ? strlen($tekst) : $konec;
|
||||
|
||||
$vyrazhenie = substr($tekst, $granica, $konec - $granica);
|
||||
|
||||
// Один и тот же оператор находится по нескольким якорям сразу —
|
||||
// показываем его один раз.
|
||||
if (isset($vidennye[$granica])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pishet = str_contains($vyrazhenie, '->update(')
|
||||
|| str_contains($vyrazhenie, '->upsert(')
|
||||
|| str_contains($vyrazhenie, '->updateOrInsert(');
|
||||
|
||||
$pro_nash_stolbec = str_contains($vyrazhenie, "'outcome'")
|
||||
|| str_contains($vyrazhenie, '"outcome"');
|
||||
|
||||
if (! $pishet || ! $pro_nash_stolbec) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$vidennye[$granica] = true;
|
||||
$uliki[] = [substr_count($tekst, "\n", 0, $nachalo) + 1, trim(preg_replace('/\s+/', ' ', $vyrazhenie) ?? '')];
|
||||
}
|
||||
|
||||
// Сырой SQL по той же таблице.
|
||||
if (preg_match_all('/update\s+obzvon_number_results/i', $tekst, $sovpadeniya, PREG_OFFSET_CAPTURE)) {
|
||||
foreach ($sovpadeniya[0] as [$kusok, $poziciya]) {
|
||||
$uliki[] = [substr_count($tekst, "\n", 0, (int) $poziciya) + 1, 'сырой SQL: '.$kusok];
|
||||
}
|
||||
}
|
||||
|
||||
return $uliki;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Autopodbor\Agent\Fetch\RenderServiceYandexListSource;
|
||||
use App\Services\Autopodbor\Agent\Fetch\SelfRenderClient;
|
||||
use App\Services\External\ProxyMarketProbe;
|
||||
use App\Services\Obzvon\ObzvonClient;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* 🔴 Сторожа одного класса беды: РАЗРЕШЁННАЯ машина отвечает «иди на другой адрес», и наш
|
||||
* клиент послушно едет туда — вместе с тайным ключом в заголовке.
|
||||
*
|
||||
* Дверь (разрешённый хост + шифрованная дорога) проверяет адрес ИЗ НАСТРОЙКИ. Про второй
|
||||
* адрес, который прилетел ответом, она ничего не знает и знать не может. А перевозчик Guzzle
|
||||
* при уходе на чужой адрес снимает ровно два заголовка — `Authorization` и `Cookie`
|
||||
* (`vendor/guzzlehttp/guzzle/src/RedirectMiddleware.php`, строки 199–202). Наши `X-Render-Key`
|
||||
* и `X-Obzvon-Key` не из них и едут дальше.
|
||||
*
|
||||
* 🪤 Чего эти сторожа НАМЕРЕННО не делают: не ищут слов в тексте исходника. Сторож, читающий
|
||||
* файл, находит нужное слово в собственном пояснении и зеленеет при сломанном коде — этим в
|
||||
* проекте уже платили. Здесь смотрят на РАБОТУ: куда физически ушёл запрос и что он вёз.
|
||||
*
|
||||
* 🪤 Второе: сторож обязан УМЕТЬ покраснеть. Ниже есть отдельная проверка `датчик сам жив`,
|
||||
* которая доказывает, что подставная сеть за переадресацией ИДЁТ. Погасни она — все остальные
|
||||
* сторожа этого файла станут пустыми, и она скажет об этом вслух, а не молча.
|
||||
*/
|
||||
|
||||
/** Разрешённая машина: она в списке, дорога шифрованная — дверь её пропускает. */
|
||||
const SVOYA = 'svoya-vm.test';
|
||||
|
||||
/** Чужая машина: в списке её НЕТ. Ключ не имеет права появиться здесь ни разу. */
|
||||
const CHUZHAYA = 'chuzhaya-vm.test';
|
||||
|
||||
const KLYUCH_RENDERA = 'tainyy-klyuch-renderа-777';
|
||||
|
||||
const KLYUCH_OBZVONA = 'tainyy-klyuch-robota-777';
|
||||
|
||||
beforeEach(function () {
|
||||
// Ни один сторож этого файла не имеет права уйти в настоящую сеть.
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
/**
|
||||
* Подставная сеть: разрешённая машина отвечает «иди на чужую», чужая отвечает успехом.
|
||||
*
|
||||
* Чужая отвечает именно УСПЕХОМ намеренно: если запрет не встал, поход закончится хорошо и
|
||||
* зелёный результат сам по себе беду не покажет. Показывает её только запись переговоров.
|
||||
*/
|
||||
function setPereadresuyushchaya(): void
|
||||
{
|
||||
Http::fake([
|
||||
SVOYA.'/*' => Http::response('', 302, ['Location' => 'http://'.CHUZHAYA.'/ukradi']),
|
||||
CHUZHAYA.'/*' => Http::response(['data' => 'ukradeno', 'ok' => true, 'ip' => '1.2.3.4'], 200),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Записи всех обращений, дошедших до ЧУЖОЙ машины, — с указанием, вёз ли запрос тайный ключ.
|
||||
*
|
||||
* @return list<array{url:string,klyuch:string}>
|
||||
*/
|
||||
function dokudaDoehalKlyuch(string $imyaZagolovka): array
|
||||
{
|
||||
return Http::recorded()
|
||||
->map(fn (array $para): Request => $para[0])
|
||||
->filter(fn (Request $z): bool => str_contains((string) parse_url($z->url(), PHP_URL_HOST), CHUZHAYA))
|
||||
->map(fn (Request $z): array => [
|
||||
'url' => $z->url(),
|
||||
'klyuch' => $z->hasHeader($imyaZagolovka) ? $z->header($imyaZagolovka)[0] : '',
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 0. 🔴 ДАТЧИК САМ ЖИВ. Без этой проверки все остальные сторожа файла могли бы
|
||||
// зеленеть просто потому, что подставная сеть переадресацию не отрабатывает.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('датчик сам жив: подставная сеть ИДЁТ за переадресацией и везёт заголовок дальше', function () {
|
||||
setPereadresuyushchaya();
|
||||
|
||||
// Это НЕ наш код — это голый перевозчик. Проверяем сам измерительный прибор.
|
||||
Http::withHeaders(['X-Render-Key' => KLYUCH_RENDERA])->get('https://'.SVOYA.'/render');
|
||||
|
||||
expect(dokudaDoehalKlyuch('X-Render-Key'))->toBe([
|
||||
['url' => 'http://'.CHUZHAYA.'/ukradi', 'klyuch' => KLYUCH_RENDERA],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Поиск клиентов, одиночный запрос (SelfRenderClient::html)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('поиск клиентов, одиночный запрос: за переадресацией не идём, ключ остаётся дома', function () {
|
||||
setPereadresuyushchaya();
|
||||
|
||||
$klient = new SelfRenderClient(
|
||||
endpoint: 'https://'.SVOYA.'/render',
|
||||
key: KLYUCH_RENDERA,
|
||||
retries: 1,
|
||||
razreshyonnyeHosty: [SVOYA],
|
||||
);
|
||||
|
||||
$stranica = $klient->html('https://2gis.ru/x');
|
||||
|
||||
// 🔴 Главная проверка — первой: ключ не имеет права оказаться на чужой машине.
|
||||
expect(dokudaDoehalKlyuch('X-Render-Key'))->toBe([]);
|
||||
// И чужой ответ не выдан за нашу страницу.
|
||||
expect($stranica)->toBe('');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. 🔴 Поиск клиентов, отправка ПАЧКОЙ (Http::pool). У пачки своё построение
|
||||
// запроса — «поставил в одном месте, встало и в другом» тут не работает.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('поиск клиентов, отправка пачкой: за переадресацией не идём, ключ остаётся дома', function () {
|
||||
setPereadresuyushchaya();
|
||||
|
||||
$klient = new SelfRenderClient(
|
||||
endpoint: 'https://'.SVOYA.'/render',
|
||||
key: KLYUCH_RENDERA,
|
||||
concurrency: 4,
|
||||
retries: 1,
|
||||
razreshyonnyeHosty: [SVOYA],
|
||||
);
|
||||
|
||||
$out = $klient->htmlBatch(['https://2gis.ru/a', 'https://2gis.ru/b']);
|
||||
|
||||
expect(dokudaDoehalKlyuch('X-Render-Key'))->toBe([]);
|
||||
expect($out)->toBe(['https://2gis.ru/a' => '', 'https://2gis.ru/b' => '']);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. Лента Яндекс.Карт (RenderServiceYandexListSource) — тот же ключ, та же дверь
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('лента Яндекса: за переадресацией не идём, ключ остаётся дома', function () {
|
||||
setPereadresuyushchaya();
|
||||
|
||||
$istochnik = new RenderServiceYandexListSource(
|
||||
endpoint: 'https://'.SVOYA.'/render',
|
||||
key: KLYUCH_RENDERA,
|
||||
razreshyonnyeHosty: [SVOYA],
|
||||
);
|
||||
|
||||
$lenta = $istochnik->fetch('https://yandex.ru/maps/org/1', 3);
|
||||
|
||||
expect(dokudaDoehalKlyuch('X-Render-Key'))->toBe([]);
|
||||
expect($lenta)->toBe([]);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 4. Плитка живости прокси (ProxyMarketProbe) — третья дверь на том же ключе
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('плитка прокси: за переадресацией не идём, ключ остаётся дома', function () {
|
||||
setPereadresuyushchaya();
|
||||
config()->set('services.proxy_market.check_url', 'https://'.SVOYA.'/proxy-check');
|
||||
config()->set('services.proxy_market.expires_at', now()->addDays(30)->format('Y-m-d'));
|
||||
config()->set('services.self_render.key', KLYUCH_RENDERA);
|
||||
config()->set('services.self_render.allowed_hosts', [SVOYA]);
|
||||
|
||||
$chtenie = (new ProxyMarketProbe)->check();
|
||||
|
||||
expect(dokudaDoehalKlyuch('X-Render-Key'))->toBe([]);
|
||||
// 🔴 И плитка не врёт зелёным: 302 — это не «ок».
|
||||
expect($chtenie->light)->not->toBe('green');
|
||||
});
|
||||
|
||||
it('плитка прокси НЕ выдаёт чужой ответ за свой — зелёного по переадресации не бывает', function () {
|
||||
setPereadresuyushchaya();
|
||||
config()->set('services.proxy_market.check_url', 'https://'.SVOYA.'/proxy-check');
|
||||
config()->set('services.proxy_market.expires_at', now()->addDays(30)->format('Y-m-d'));
|
||||
config()->set('services.self_render.key', KLYUCH_RENDERA);
|
||||
config()->set('services.self_render.allowed_hosts', [SVOYA]);
|
||||
|
||||
$chtenie = (new ProxyMarketProbe)->check();
|
||||
|
||||
// Чужая машина отдаёт `{"ok":true,"ip":"1.2.3.4"}` — ровно тот ответ, от которого
|
||||
// плитка загорелась бы зелёным и показала владельцу ЧУЖОЙ IP как свой.
|
||||
expect($chtenie->detail)->not->toContain('1.2.3.4');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 5. Дверь к голосовому роботу (ObzvonClient) — другой ключ, та же беда
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('голосовой робот: за переадресацией не идём, ключ остаётся дома', function () {
|
||||
setPereadresuyushchaya();
|
||||
config()->set('services.obzvon.allowed_hosts', [SVOYA]);
|
||||
|
||||
$klient = new ObzvonClient(
|
||||
endpoint: 'https://'.SVOYA.'/obzvon',
|
||||
key: KLYUCH_OBZVONA,
|
||||
concurrency: 4,
|
||||
waitSeconds: 0,
|
||||
connectTimeout: 5,
|
||||
timeout: 15,
|
||||
razreshyonnyeHosty: [SVOYA],
|
||||
);
|
||||
|
||||
$klient->pozvonit(tenantId: 1, campaignId: 7, telefon: '+7 999 123-45-67', popytkaNo: 1);
|
||||
|
||||
expect(dokudaDoehalKlyuch('X-Obzvon-Key'))->toBe([]);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 6. 🔴 Переадресация с шифрованной дороги на НЕшифрованную — отдельная беда:
|
||||
// ключ уехал бы открытым текстом даже без смены машины.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('переадресация с https на http к той же машине не увозит ключ открытым текстом', function () {
|
||||
Http::fake([
|
||||
'https://'.SVOYA.'/*' => Http::response('', 302, ['Location' => 'http://'.SVOYA.'/render']),
|
||||
'http://'.SVOYA.'/*' => Http::response(['data' => 'otkrytym-tekstom'], 200),
|
||||
]);
|
||||
|
||||
$klient = new SelfRenderClient(
|
||||
endpoint: 'https://'.SVOYA.'/render',
|
||||
key: KLYUCH_RENDERA,
|
||||
retries: 1,
|
||||
razreshyonnyeHosty: [SVOYA],
|
||||
);
|
||||
|
||||
$stranica = $klient->html('https://2gis.ru/x');
|
||||
|
||||
$pohttp = Http::recorded()
|
||||
->map(fn (array $para): Request => $para[0])
|
||||
->filter(fn (Request $z): bool => str_starts_with($z->url(), 'http://'))
|
||||
->count();
|
||||
|
||||
expect($pohttp)->toBe(0);
|
||||
expect($stranica)->toBe('');
|
||||
});
|
||||
@@ -37,6 +37,7 @@ function makeApiDetail(overrides: Partial<AdminTenantDetailResponse> = {}): Admi
|
||||
desired_daily_numbers: 12,
|
||||
chargeback_unrecovered_rub: '0.00',
|
||||
created_at: '2025-09-12T00:00:00+00:00',
|
||||
aliveness_state: 'cooling',
|
||||
},
|
||||
users: [
|
||||
{
|
||||
@@ -91,24 +92,32 @@ function makeApiDetail(overrides: Partial<AdminTenantDetailResponse> = {}): Admi
|
||||
created_at: '2026-05-09T08:42:00+00:00',
|
||||
},
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
id: 1,
|
||||
event: 'webhook.received',
|
||||
deal_id: 4471,
|
||||
actor_email: null,
|
||||
context: null,
|
||||
created_at: '2026-05-09T08:42:00+00:00',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
event: 'deal.status_changed',
|
||||
deal_id: 4470,
|
||||
actor_email: 'ivan@okna-moscow.ru',
|
||||
context: { from: 'viewed', to: 'in_progress' },
|
||||
created_at: '2026-05-09T07:18:00+00:00',
|
||||
},
|
||||
],
|
||||
aliveness: {
|
||||
state: 'cooling',
|
||||
last_login_at: '2026-08-06T03:23:12+00:00',
|
||||
days_since_login: 6,
|
||||
last_login_ip: '5.166.40.65',
|
||||
last_login_user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0 Safari/537.36',
|
||||
days: [{ date: '2026-08-06', visits: 1 }],
|
||||
counters: { visits_30d: 2, active_days_30d: 2, actions_30d: 1, failed_logins_30d: 0 },
|
||||
},
|
||||
activity: {
|
||||
items: [
|
||||
{
|
||||
id: 'ops-1',
|
||||
at: '2026-08-05T07:34:48+00:00',
|
||||
kind: 'project',
|
||||
event: 'project.created',
|
||||
actor: 'admin@okna-moscow.ru',
|
||||
subject: 'Окна ЕКБ',
|
||||
amount_rub: null,
|
||||
bulk_count: 0,
|
||||
},
|
||||
],
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
has_more: false,
|
||||
},
|
||||
metrics: {
|
||||
leads_today: 11,
|
||||
leads_this_week: 42,
|
||||
@@ -210,7 +219,9 @@ describe('AdminTenantDetailView.vue (API integration)', () => {
|
||||
expect(text).toContain('natyazhnye-potolki');
|
||||
});
|
||||
|
||||
it('переключение таба Активность показывает event-коды + actor + summary', async () => {
|
||||
// 07.08.2026: вкладка больше не показывает коды событий. Она отвечает
|
||||
// «жив ли клиент»: вердикт наверху, а ниже дела человеческой фразой.
|
||||
it('переключение таба Активность показывает вердикт и дела по-русски', async () => {
|
||||
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
|
||||
const wrapper = await mountDetail('okna-moscow');
|
||||
const vm = wrapper.vm as unknown as { activeTab: string };
|
||||
@@ -218,10 +229,10 @@ describe('AdminTenantDetailView.vue (API integration)', () => {
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-testid="pane-activity"]').exists()).toBe(true);
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('webhook.received');
|
||||
expect(text).toContain('deal.status_changed');
|
||||
expect(text).toContain('ivan@okna-moscow.ru'); // actor_email
|
||||
expect(text).toContain('viewed → in_progress'); // summary из context
|
||||
expect(text).toContain('Не заходил 6 дней');
|
||||
expect(text).toContain('создал проект «Окна ЕКБ»');
|
||||
expect(text).toContain('admin@okna-moscow.ru');
|
||||
expect(text).not.toContain('project.created'); // кода события на экране нет
|
||||
});
|
||||
|
||||
it('кнопка «Войти как клиент» open impersonationDialog', async () => {
|
||||
@@ -293,4 +304,23 @@ describe('AdminTenantDetailView.vue (API integration)', () => {
|
||||
expect(wrapper.text()).toContain('Trial');
|
||||
expect(wrapper.text()).toContain('без оплаты');
|
||||
});
|
||||
it('на вкладке «Пользователи» колонка называется «Последний вход»', async () => {
|
||||
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
|
||||
const wrapper = await mountDetail('okna-moscow');
|
||||
await wrapper.find('[data-testid="tab-users"]').trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('Последний вход');
|
||||
expect(wrapper.text()).not.toContain('Последняя активность');
|
||||
});
|
||||
|
||||
it('на вкладке «Активность» показывает вердикт, а не журнал сделок', async () => {
|
||||
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
|
||||
const wrapper = await mountDetail('okna-moscow');
|
||||
await wrapper.find('[data-testid="tab-activity"]').trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="pane-activity"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('Не заходил 6 дней');
|
||||
expect(wrapper.text()).toContain('создал проект «Окна ЕКБ»');
|
||||
expect(wrapper.text()).not.toContain('deal.created');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,4 +253,12 @@ describe('AdminTenantsView.vue', () => {
|
||||
expect(vm.tenantsState.length).toBe(0);
|
||||
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
|
||||
});
|
||||
it('красит точку состояния рядом с активностью', async () => {
|
||||
const wrapper = await factory();
|
||||
|
||||
const dots = wrapper.findAll('[data-testid="aliveness-dot"]');
|
||||
expect(dots.length).toBe(MOCK_TENANTS.length);
|
||||
expect(dots[0]!.classes()).toContain('dot-active');
|
||||
expect(dots[1]!.classes()).toContain('dot-lost');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ function makeApiTenant(overrides: Partial<ApiAdminTenant> = {}): ApiAdminTenant
|
||||
balance_leads: 10,
|
||||
is_trial: false,
|
||||
last_activity_at: new Date().toISOString(),
|
||||
aliveness_state: 'active',
|
||||
tariff_id: null,
|
||||
tariff_name: 'Команда',
|
||||
mrr_rub: '990.00',
|
||||
@@ -180,9 +181,11 @@ describe('mapApiAdminTenant', () => {
|
||||
expect(m.activitySince).toBe('10 мин назад');
|
||||
});
|
||||
|
||||
it('last_activity_at=null → activitySince=«—»', () => {
|
||||
// Пусто теперь означает не «неизвестно», а точный ответ: за окно в 90 дней,
|
||||
// которое запрашивает список, клиент не заходил ни разу.
|
||||
it('last_activity_at=null → activitySince=«больше 90 дней»', () => {
|
||||
const m = mapApiAdminTenant(makeApiTenant({ last_activity_at: null }));
|
||||
expect(m.activitySince).toBe('—');
|
||||
expect(m.activitySince).toBe('больше 90 дней');
|
||||
});
|
||||
|
||||
it('mrr_rub строка → number', () => {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import TenantActivityPane from '../../resources/js/components/admin/tenant-detail/TenantActivityPane.vue';
|
||||
import type { TenantActivityItem, TenantAliveness } from '../../resources/js/composables/mockTenantDetail';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
const aliveness: TenantAliveness = {
|
||||
state: 'cooling',
|
||||
lastLoginAt: '2026-08-06T03:23:12+00:00',
|
||||
daysSinceLogin: 6,
|
||||
lastLoginIp: '5.166.40.65',
|
||||
lastLoginUserAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0 Safari/537.36',
|
||||
days: [{ date: '2026-08-06', visits: 1 }],
|
||||
visits30d: 2,
|
||||
activeDays30d: 2,
|
||||
actions30d: 2,
|
||||
failedLogins30d: 4,
|
||||
};
|
||||
|
||||
const items: TenantActivityItem[] = [
|
||||
{
|
||||
id: 'ops-1',
|
||||
at: '2026-08-05T07:34:48+00:00',
|
||||
kind: 'project',
|
||||
event: 'project.created',
|
||||
actor: 'client@example.test',
|
||||
subject: 'Окна ЕКБ',
|
||||
amountRub: null,
|
||||
bulkCount: 0,
|
||||
},
|
||||
];
|
||||
|
||||
function mountPane(props = {}) {
|
||||
return mount(TenantActivityPane, {
|
||||
global: { plugins: [vuetify] },
|
||||
props: { aliveness, items, hasMore: false, ...props },
|
||||
});
|
||||
}
|
||||
|
||||
describe('панель «Активность»', () => {
|
||||
it('показывает вердикт и устройство последнего входа', () => {
|
||||
const text = mountPane().text();
|
||||
expect(text).toContain('Не заходил 6 дней');
|
||||
expect(text).toContain('Windows/Chrome');
|
||||
expect(text).toContain('5.166.40.65');
|
||||
});
|
||||
|
||||
it('показывает четыре счётчика', () => {
|
||||
const text = mountPane().text();
|
||||
expect(text).toContain('Визитов за 30 дней');
|
||||
expect(text).toContain('Дней с визитами');
|
||||
expect(text).toContain('Действий');
|
||||
expect(text).toContain('Не смог войти');
|
||||
});
|
||||
|
||||
it('красит «не смог войти», когда попытки были', () => {
|
||||
const wrapper = mountPane();
|
||||
expect(wrapper.find('[data-testid="failed-logins"]').classes()).toContain('text-error');
|
||||
});
|
||||
|
||||
it('строит полоску ровно на 90 суток', () => {
|
||||
expect(mountPane().findAll('[data-testid="strip-day"]')).toHaveLength(90);
|
||||
});
|
||||
|
||||
it('пишет дела человеческой фразой', () => {
|
||||
expect(mountPane().text()).toContain('создал проект «Окна ЕКБ»');
|
||||
});
|
||||
|
||||
it('честно говорит, когда дел не было', () => {
|
||||
expect(mountPane({ items: [] }).text()).toContain('Ничего не менял');
|
||||
});
|
||||
|
||||
it('просит следующую страницу по кнопке', async () => {
|
||||
const wrapper = mountPane({ hasMore: true });
|
||||
await wrapper.find('[data-testid="load-more"]').trigger('click');
|
||||
expect(wrapper.emitted('load-more')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('прячет кнопку, когда больше нечего грузить', () => {
|
||||
expect(mountPane({ hasMore: false }).find('[data-testid="load-more"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -20,11 +20,21 @@ function makeApi(overrides: Partial<AdminTenantDetailResponse> = {}): AdminTenan
|
||||
desired_daily_numbers: 10,
|
||||
chargeback_unrecovered_rub: '0.00',
|
||||
created_at: '2025-01-01T00:00:00+00:00',
|
||||
aliveness_state: 'active',
|
||||
},
|
||||
users: [],
|
||||
projects: [],
|
||||
balance_history: [],
|
||||
activity: [],
|
||||
aliveness: {
|
||||
state: 'no_data',
|
||||
last_login_at: null,
|
||||
days_since_login: null,
|
||||
last_login_ip: null,
|
||||
last_login_user_agent: null,
|
||||
days: [],
|
||||
counters: { visits_30d: 0, active_days_30d: 0, actions_30d: 0, failed_logins_30d: 0 },
|
||||
},
|
||||
activity: { items: [], offset: 0, limit: 50, has_more: false },
|
||||
metrics: {
|
||||
leads_today: 5,
|
||||
leads_this_week: 30,
|
||||
@@ -204,32 +214,37 @@ describe('adminTenantDetailMapper', () => {
|
||||
expect(ui.balanceHistory[1]!.type).toBe('topup');
|
||||
});
|
||||
|
||||
it('activity: actor=actor_email; null → system', () => {
|
||||
// 07.08.2026: лента отдаётся страницей структурных дел, а не плоским
|
||||
// массивом событий сделок с готовой строкой summary. Русскую фразу теперь
|
||||
// складывает tenantActivityFormatters, маппер только переносит поля.
|
||||
it('activity: переносит дела страницей и хранит признак «есть ещё»', () => {
|
||||
const ui = mapAdminTenantDetail(
|
||||
makeApi({
|
||||
activity: [
|
||||
{
|
||||
id: 1,
|
||||
event: 'webhook.received',
|
||||
deal_id: 100,
|
||||
actor_email: null,
|
||||
context: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
event: 'deal.status_changed',
|
||||
deal_id: 200,
|
||||
actor_email: 'user@test.io',
|
||||
context: { from: 'new', to: 'in_progress' },
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
activity: {
|
||||
items: [
|
||||
{
|
||||
id: 'tx-9',
|
||||
at: '2026-01-01T00:00:00Z',
|
||||
kind: 'money',
|
||||
event: 'balance.topup',
|
||||
actor: 'user@test.io',
|
||||
subject: null,
|
||||
amount_rub: '1000.00',
|
||||
bulk_count: 0,
|
||||
},
|
||||
],
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
has_more: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(ui.activity[0]!.actor).toBe('system');
|
||||
expect(ui.activity[1]!.actor).toBe('user@test.io');
|
||||
expect(ui.activity[1]!.summary).toBe('Сделка #200: new → in_progress');
|
||||
expect(ui.activity).toHaveLength(1);
|
||||
expect(ui.activity[0]!.id).toBe('tx-9');
|
||||
expect(ui.activity[0]!.kind).toBe('money');
|
||||
expect(ui.activity[0]!.actor).toBe('user@test.io');
|
||||
expect(ui.activity[0]!.amountRub).toBe(1000);
|
||||
expect(ui.activityHasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('metrics: leadsThisMonth/Week/avgLeadCost/runwayDays', () => {
|
||||
@@ -272,3 +287,73 @@ describe('adminTenantDetailMapper', () => {
|
||||
expect(ui2.activitySince).toBe('не активен');
|
||||
});
|
||||
});
|
||||
|
||||
describe('маппер: блок «жив ли клиент»', () => {
|
||||
const base = {
|
||||
tenant: {
|
||||
id: 11,
|
||||
subdomain: 'ekb96',
|
||||
organization_name: 'ekb96',
|
||||
contact_email: 'ekb96@well.ru',
|
||||
status: 'active',
|
||||
balance_rub: '1320.00',
|
||||
balance_leads: 0,
|
||||
is_trial: true,
|
||||
last_activity_at: null,
|
||||
tariff_id: null,
|
||||
tariff_name: null,
|
||||
mrr_rub: null,
|
||||
desired_daily_numbers: null,
|
||||
chargeback_unrecovered_rub: '0.00',
|
||||
created_at: '2026-08-05T07:04:34+00:00',
|
||||
aliveness_state: 'cooling',
|
||||
},
|
||||
users: [],
|
||||
projects: [],
|
||||
balance_history: [],
|
||||
aliveness: {
|
||||
state: 'cooling',
|
||||
last_login_at: '2026-08-06T03:23:12+00:00',
|
||||
days_since_login: 1,
|
||||
last_login_ip: '5.166.40.65',
|
||||
last_login_user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||
days: [{ date: '2026-08-06', visits: 1 }],
|
||||
counters: { visits_30d: 2, active_days_30d: 2, actions_30d: 2, failed_logins_30d: 0 },
|
||||
},
|
||||
activity: {
|
||||
items: [
|
||||
{
|
||||
id: 'ops-1',
|
||||
at: '2026-08-05T07:34:48+00:00',
|
||||
kind: 'project',
|
||||
event: 'project.created',
|
||||
actor: 'ekb96@well.ru',
|
||||
subject: 'Окна ЕКБ',
|
||||
amount_rub: null,
|
||||
bulk_count: 0,
|
||||
},
|
||||
],
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
has_more: false,
|
||||
},
|
||||
metrics: {
|
||||
leads_today: 0,
|
||||
leads_this_week: 12,
|
||||
leads_this_month: 12,
|
||||
avg_lead_cost_rub: 15.25,
|
||||
runway_days: 59,
|
||||
},
|
||||
} as never;
|
||||
|
||||
it('переносит вердикт и ленту без потерь', () => {
|
||||
const mapped = mapAdminTenantDetail(base);
|
||||
|
||||
expect(mapped.aliveness.state).toBe('cooling');
|
||||
expect(mapped.aliveness.daysSinceLogin).toBe(1);
|
||||
expect(mapped.aliveness.days).toHaveLength(1);
|
||||
expect(mapped.activity).toHaveLength(1);
|
||||
expect(mapped.activity[0].subject).toBe('Окна ЕКБ');
|
||||
expect(mapped.activityHasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,4 +12,11 @@ describe('строка proxy_market на плашке', () => {
|
||||
expect(proxyStatus('amber')).toBe('скоро истекает');
|
||||
expect(proxyStatus('red')).toBe('не отвечает');
|
||||
});
|
||||
|
||||
// 🔴 Р126, третья дверь. Серый = «не проверяем», красный = «прокси кончились». Подпись
|
||||
// «не отвечает» на сером погнала бы владельца продлевать исправные прокси.
|
||||
it('серый НЕ выдаёт себя за красный', () => {
|
||||
expect(proxyStatus('grey')).toBe('не проверяем');
|
||||
expect(proxyStatus('grey')).not.toBe(proxyStatus('red'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TenantActivityItem } from '../../resources/js/composables/mockTenantDetail';
|
||||
import {
|
||||
activityPhrase,
|
||||
alivenessColor,
|
||||
alivenessShortLabel,
|
||||
alivenessTitle,
|
||||
deviceLabel,
|
||||
} from '../../resources/js/composables/tenantActivityFormatters';
|
||||
|
||||
/**
|
||||
* Неразрывный пробел — им русская локаль Intl разделяет разряды («1 000 ₽»).
|
||||
* Записан кодом нарочно: невидимый символ в исходнике теряется при правках,
|
||||
* и тест начинает падать «на ровном месте», хотя на экране всё одинаково.
|
||||
*/
|
||||
const NBSP = String.fromCharCode(160);
|
||||
|
||||
function item(patch: Partial<TenantActivityItem>): TenantActivityItem {
|
||||
return {
|
||||
id: 'ops-1',
|
||||
at: '2026-08-05T07:34:48+00:00',
|
||||
kind: 'project',
|
||||
event: 'project.created',
|
||||
actor: 'client@example.test',
|
||||
subject: 'Двери',
|
||||
amountRub: null,
|
||||
bulkCount: 0,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe('фразы ленты', () => {
|
||||
it('пишет название проекта, а не номер', () => {
|
||||
expect(activityPhrase(item({}))).toBe('создал проект «Двери»');
|
||||
});
|
||||
|
||||
it('пишет, сколько проектов задето групповым действием', () => {
|
||||
expect(activityPhrase(item({ event: 'project.bulk_pause', subject: null, bulkCount: 3 })))
|
||||
.toBe('остановил проекты: 3');
|
||||
});
|
||||
|
||||
it('пишет номер сделки', () => {
|
||||
expect(activityPhrase(item({ kind: 'deal', event: 'deal.status_changed', subject: '555' })))
|
||||
.toBe('сменил статус сделки #555');
|
||||
});
|
||||
|
||||
it('пишет сумму пополнения', () => {
|
||||
expect(activityPhrase(item({ kind: 'money', event: 'balance.topup', subject: null, amountRub: 1000 })))
|
||||
.toBe(`пополнил баланс на 1${NBSP}000 ₽`);
|
||||
});
|
||||
|
||||
it('не падает на незнакомом событии', () => {
|
||||
expect(activityPhrase(item({ event: 'nechto.strannoe', subject: null }))).toBe('nechto.strannoe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('расшифровка устройства', () => {
|
||||
it('узнаёт Windows и Chrome', () => {
|
||||
expect(deviceLabel('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36'))
|
||||
.toBe('Windows/Chrome');
|
||||
});
|
||||
|
||||
it('узнаёт айфон и Safari', () => {
|
||||
expect(deviceLabel('Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Version/17.5 Mobile/15E148 Safari/604.1'))
|
||||
.toBe('iPhone/Safari');
|
||||
});
|
||||
|
||||
it('честно молчит, когда не знает', () => {
|
||||
expect(deviceLabel(null)).toBe('устройство неизвестно');
|
||||
expect(deviceLabel('curl/8.4.0')).toBe('устройство неизвестно');
|
||||
});
|
||||
});
|
||||
|
||||
describe('вердикт', () => {
|
||||
it('зелёный, когда заходил на днях', () => {
|
||||
expect(alivenessColor('active')).toBe('success');
|
||||
expect(alivenessTitle('active', 0)).toBe('Заходил сегодня');
|
||||
expect(alivenessTitle('active', 1)).toBe('Заходил вчера');
|
||||
expect(alivenessTitle('active', 3)).toBe('Заходил 3 дня назад');
|
||||
});
|
||||
|
||||
it('жёлтый, когда остывает', () => {
|
||||
expect(alivenessColor('cooling')).toBe('warning');
|
||||
expect(alivenessTitle('cooling', 6)).toBe('Не заходил 6 дней');
|
||||
});
|
||||
|
||||
it('красный, когда пропал или ни разу не заходил', () => {
|
||||
expect(alivenessColor('lost')).toBe('error');
|
||||
expect(alivenessTitle('lost', 21)).toBe('Не заходил 21 день');
|
||||
expect(alivenessColor('never_logged_in')).toBe('error');
|
||||
expect(alivenessTitle('never_logged_in', null)).toBe('Ни разу не заходил после регистрации');
|
||||
});
|
||||
|
||||
it('серый, когда данных нет', () => {
|
||||
expect(alivenessColor('no_data')).toBe('grey');
|
||||
expect(alivenessTitle('no_data', null)).toBe('Нет данных о входах');
|
||||
});
|
||||
|
||||
it('даёт короткую подпись для подсказки в списке', () => {
|
||||
expect(alivenessShortLabel('active')).toBe('заходит');
|
||||
expect(alivenessShortLabel('cooling')).toBe('остывает');
|
||||
expect(alivenessShortLabel('lost')).toBe('пропал');
|
||||
expect(alivenessShortLabel('never_logged_in')).toBe('ни разу не заходил');
|
||||
expect(alivenessShortLabel('no_data')).toBe('нет данных');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Providers\AutopodborServiceProvider;
|
||||
use App\Services\Autopodbor\Agent\Fetch\DverRendera;
|
||||
use App\Services\Autopodbor\Agent\Fetch\RenderServiceYandexListSource;
|
||||
use App\Services\Autopodbor\Agent\Fetch\SelfRenderClient;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
// Р126. Дверь к рендер-виртуалке: тайный ключ SELF_RENDER_KEY едет ЗАГОЛОВКОМ X-Render-Key в
|
||||
// каждом запросе. Опечатка в адресе увела бы его чужому молча и навсегда; `http://` увёз бы его
|
||||
// открытым текстом. Устройство повторяет проверенную дверь к голосовому роботу (Р123).
|
||||
|
||||
/** Хосты, куда разрешено в этих проверках. Боевой список — в config/services.php. */
|
||||
const HOSTY = ['render.local', '51-250-1-97.sslip.io'];
|
||||
|
||||
function dver(string $endpoint, string $key = 'secret', ?array $hosty = null): DverRendera
|
||||
{
|
||||
return new DverRendera($endpoint, $key, $hosty ?? HOSTY);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Две причины «нет настройки» — каждая СВОИМИ словами, а не одним «не готово»
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('пустой адрес — закрыта, и сказано именно про адрес', function () {
|
||||
expect(dver('')->pochemuZakryta())->toContain('не задан адрес');
|
||||
});
|
||||
|
||||
it('пустой ключ — закрыта, и сказано именно про ключ', function () {
|
||||
expect(dver('https://render.local/render', '')->pochemuZakryta())->toContain('не задан тайный ключ');
|
||||
});
|
||||
|
||||
it('пробелы вокруг пустого адреса не считаются адресом', function () {
|
||||
expect(dver(' ')->pochemuZakryta())->toContain('не задан адрес');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Разрешённые машины. Пустой список = закрыто ВСЁ
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('пустой список разрешённых машин закрывает даже правильный адрес', function () {
|
||||
expect(dver('https://render.local/render', 'secret', [])->pochemuZakryta())
|
||||
->toContain('неразрешённый хост');
|
||||
});
|
||||
|
||||
it('чужой хост закрыт, и хост назван вслух', function () {
|
||||
expect(dver('https://render.local.zloumyshlennik.ru/render')->pochemuZakryta())
|
||||
->toContain('render.local.zloumyshlennik.ru');
|
||||
});
|
||||
|
||||
it('хост сверяется без оглядки на регистр букв', function () {
|
||||
expect(dver('https://RENDER.LOCAL/render')->pochemuZakryta())->toBe('');
|
||||
});
|
||||
|
||||
it('хост из списка, записанного заглавными, тоже узнаётся', function () {
|
||||
expect(dver('https://render.local/render', 'secret', ['RENDER.LOCAL'])->pochemuZakryta())->toBe('');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 🔴 ВОСЕМЬ ФОРМ АДРЕСА — та же приёмка, что прошла дверь голосового робота
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('восемь форм адреса разбираются ровно так, как решил владелец', function (string $adres, bool $otkryta) {
|
||||
expect(dver($adres)->pochemuZakryta() === '')->toBe($otkryta);
|
||||
})->with([
|
||||
'https обычный' => ['https://render.local/render', true],
|
||||
'HTTPS большими буквами' => ['HTTPS://render.local/render', true],
|
||||
'https с портом' => ['https://render.local:8790/render', true],
|
||||
'https с пробелами по краям' => [' https://render.local/render ', true],
|
||||
'http — ключ уехал бы открытым' => ['http://render.local/render', false],
|
||||
'httpsX — не https' => ['httpsX://render.local/render', false],
|
||||
'http, а https внутри пути' => ['http://render.local/https://render', false],
|
||||
'вовсе без схемы' => ['render.local/render', false],
|
||||
]);
|
||||
|
||||
it('боевой адрес виртуалки дверь ПРОПУСКАЕТ', function () {
|
||||
expect(dver('https://51-250-1-97.sslip.io/render')->pochemuZakryta())->toBe('');
|
||||
});
|
||||
|
||||
it('про http сказано именно «без шифрования», а не «чужой хост»', function () {
|
||||
expect(dver('http://render.local/render')->pochemuZakryta())
|
||||
->toContain('без шифрования')
|
||||
->toContain('открытым текстом');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Своя машина — ДВА независимых ответа, и оба обязаны совпасть
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 🪤 IPv6 в списке разрешённых пишется В СКОБКАХ — ровно так его отдаёт разбор адреса.
|
||||
// Замерено: без скобок хост не узнаётся. Так же устроена и проверенная дверь голосового
|
||||
// робота; расхождение между двумя дверями было бы хуже этой шероховатости.
|
||||
it('на своей машине http на петлю разрешён', function (string $adres) {
|
||||
expect(dver($adres, 'secret', ['127.0.0.1', 'localhost', '127.0.1.1', '[::1]', '[::ffff:127.0.0.1]', 'moy.localhost'])
|
||||
->pochemuZakryta())->toBe('');
|
||||
})->with([
|
||||
'http://127.0.0.1:8790/render',
|
||||
'http://127.0.1.1:8790/render',
|
||||
'http://localhost:8790/render',
|
||||
'http://moy.localhost:8790/render',
|
||||
'http://[::1]:8790/render',
|
||||
'http://[::ffff:127.0.0.1]:8790/render',
|
||||
]);
|
||||
|
||||
it('localhost внутри чужого имени петлёй НЕ считается', function () {
|
||||
expect(dver('http://localhost.chuzhoy.ru/render', 'secret', ['localhost.chuzhoy.ru'])->pochemuZakryta())
|
||||
->toContain('без шифрования');
|
||||
});
|
||||
|
||||
it('на боевом окружении петля НЕ спасает — http закрыт', function () {
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
|
||||
expect(dver('http://127.0.0.1:8790/render', 'secret', ['127.0.0.1'])->pochemuZakryta())
|
||||
->toContain('без шифрования');
|
||||
});
|
||||
|
||||
it('на боевом окружении https на разрешённый хост по-прежнему открыт', function () {
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
|
||||
expect(dver('https://51-250-1-97.sslip.io/render')->pochemuZakryta())->toBe('');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Мягкая деградация клиента: наружу НЕ ходим, отдаём пусто, причина в журнал
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function klientSDveryu(string $endpoint, array $hosty = HOSTY): SelfRenderClient
|
||||
{
|
||||
return new SelfRenderClient(
|
||||
endpoint: $endpoint,
|
||||
key: 'secret',
|
||||
concurrency: 4,
|
||||
retries: 2,
|
||||
razreshyonnyeHosty: $hosty,
|
||||
);
|
||||
}
|
||||
|
||||
it('закрытая дверь: html не ходит наружу и отдаёт пусто', function () {
|
||||
Http::fake();
|
||||
|
||||
expect(klientSDveryu('http://render.local/render')->html('https://2gis.ru/x'))->toBe('');
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('закрытая дверь: htmlBatch не ходит наружу и отдаёт пусто на каждый адрес', function () {
|
||||
Http::fake();
|
||||
|
||||
$out = klientSDveryu('https://chuzhoy.ru/render')->htmlBatch(['https://2gis.ru/a', 'https://2gis.ru/b']);
|
||||
|
||||
expect($out)->toBe(['https://2gis.ru/a' => '', 'https://2gis.ru/b' => '']);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('закрытая дверь пишет причину в журнал уровнем не ниже предупреждения', function () {
|
||||
Http::fake();
|
||||
Log::shouldReceive('warning')->once()->withArgs(
|
||||
fn (string $soobshchenie) => str_contains($soobshchenie, 'без шифрования')
|
||||
);
|
||||
|
||||
klientSDveryu('http://render.local/render')->html('https://2gis.ru/x');
|
||||
});
|
||||
|
||||
it('в журнал не попадает тайный ключ', function () {
|
||||
Http::fake();
|
||||
Log::shouldReceive('warning')->once()->withArgs(
|
||||
fn (string $soobshchenie, array $context = []) => ! str_contains($soobshchenie.json_encode($context), 'secret')
|
||||
);
|
||||
|
||||
klientSDveryu('http://render.local/render')->html('https://2gis.ru/x');
|
||||
});
|
||||
|
||||
it('открытая дверь по-прежнему ходит наружу — сегодняшняя работа не изменилась', function () {
|
||||
Http::fake(['https://render.local/render' => Http::response(['data' => '{"state":1}'], 200)]);
|
||||
|
||||
expect(klientSDveryu('https://render.local/render')->html('https://2gis.ru/x'))->toBe('{"state":1}');
|
||||
|
||||
Http::assertSent(fn ($r) => $r->hasHeader('X-Render-Key', 'secret'));
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Вторая дверь на том же ключе — лента Яндекс.Карт
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('лента Яндекса: закрытая дверь не ходит наружу и отдаёт пусто', function () {
|
||||
Http::fake();
|
||||
|
||||
$istochnik = new RenderServiceYandexListSource(
|
||||
endpoint: 'http://render.local/render',
|
||||
key: 'secret',
|
||||
httpTimeout: 5,
|
||||
razreshyonnyeHosty: HOSTY,
|
||||
);
|
||||
|
||||
expect($istochnik->fetch('https://yandex.ru/maps/x', 2))->toBe([]);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 🔴🔴 ГЛАВНОЕ: сегодняшний БОЕВОЙ поиск обязан продолжать работать
|
||||
//
|
||||
// Эти проверки не выдумывают список хостов, а берут НАСТОЯЩЕЕ умолчание из
|
||||
// config/services.php — то самое, что поедет на боевой. Если кто-нибудь опустошит
|
||||
// умолчание или уберёт передачу списка из провайдера, поиск клиентов на боевом встанет
|
||||
// в момент выката. Здесь это станет красным ДО выката, а не после.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Боевой адрес виртуалки-рендера. Замер: ПИЛОТ.md 06.07.2026, «прод её достаёт». */
|
||||
const BOEVOY = 'https://51-250-1-97.sslip.io/render';
|
||||
|
||||
it('умолчание разрешённых машин НЕ пусто — иначе боевой поиск встал бы в момент выката', function () {
|
||||
expect((array) config('services.self_render.allowed_hosts'))->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('боевой адрес виртуалки проходит дверь на НАСТОЯЩЕМ умолчании настроек', function () {
|
||||
expect(
|
||||
(new DverRendera(BOEVOY, 'secret', (array) config('services.self_render.allowed_hosts')))
|
||||
->pochemuZakryta()
|
||||
)->toBe('');
|
||||
});
|
||||
|
||||
it('боевой адрес открыт и тогда, когда приложение считает себя боевым', function () {
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
|
||||
expect(
|
||||
(new DverRendera(BOEVOY, 'secret', (array) config('services.self_render.allowed_hosts')))
|
||||
->pochemuZakryta()
|
||||
)->toBe('');
|
||||
});
|
||||
|
||||
it('провайдер ДЕЙСТВИТЕЛЬНО отдаёт список разрешённых машин ленте Яндекса', function () {
|
||||
config()->set('autopodbor.self_render', true);
|
||||
config()->set('services.self_render.endpoint', BOEVOY);
|
||||
config()->set('services.self_render.key', 'secret');
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
|
||||
Http::fake([BOEVOY => Http::response(['data' => '{"orgs":[]}'], 200)]);
|
||||
|
||||
(new AutopodborServiceProvider(app()))->yandexListSource()
|
||||
->fetch('https://yandex.ru/maps/?text=ломбард', 1);
|
||||
|
||||
// Запрос УШЁЛ — значит провайдер передал список и дверь открылась на боевом адресе.
|
||||
Http::assertSent(fn ($r) => $r->url() === BOEVOY && $r->hasHeader('X-Render-Key', 'secret'));
|
||||
});
|
||||
|
||||
it('лента Яндекса: открытая дверь по-прежнему ходит наружу', function () {
|
||||
Http::fake(['https://render.local/render' => Http::response(['data' => '{"orgs":[{"name":"А"}]}'], 200)]);
|
||||
|
||||
$istochnik = new RenderServiceYandexListSource(
|
||||
endpoint: 'https://render.local/render',
|
||||
key: 'secret',
|
||||
httpTimeout: 5,
|
||||
razreshyonnyeHosty: HOSTY,
|
||||
);
|
||||
|
||||
expect($istochnik->fetch('https://yandex.ru/maps/x', 2))->toBe([['name' => 'А']]);
|
||||
Http::assertSent(fn ($r) => $r->hasHeader('X-Render-Key', 'secret'));
|
||||
});
|
||||
@@ -15,7 +15,9 @@ it('шлёт type=yandex-list + scrolls, разбирает orgs из data', fun
|
||||
['name' => 'КрасЛомбард', 'id' => '1', 'href' => '/maps/org/kraslombard/1'],
|
||||
]])], 200)]);
|
||||
|
||||
$src = new RenderServiceYandexListSource(YEP, 'secret');
|
||||
// 🔴 Р126: список разрешённых машин пуст по умолчанию (закрыто всё), поэтому проверка
|
||||
// обязана назвать, куда ей можно, — ровно как это делает боевая настройка.
|
||||
$src = new RenderServiceYandexListSource(YEP, 'secret', razreshyonnyeHosty: ['render.local']);
|
||||
$orgs = $src->fetch('https://yandex.ru/maps/?text=ломбард', 5);
|
||||
|
||||
expect($orgs)->toHaveCount(1)
|
||||
|
||||
@@ -20,6 +20,9 @@ function client(array $over = []): SelfRenderClient
|
||||
key: $over['key'] ?? 'secret',
|
||||
concurrency: $over['concurrency'] ?? 4,
|
||||
retries: $over['retries'] ?? 2,
|
||||
// 🔴 Р126: список разрешённых машин пуст по умолчанию (закрыто всё), поэтому проверка
|
||||
// обязана назвать, куда ей можно, — ровно как это делает боевая настройка.
|
||||
razreshyonnyeHosty: $over['razreshyonnyeHosty'] ?? ['render.local'],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,8 @@ it('proxy_market: переход в red шлёт письмо-алерт', funct
|
||||
config()->set('services.proxy_market.check_url', 'https://vm.local/proxy-check');
|
||||
config()->set('services.proxy_market.expires_at', now()->addDays(30)->format('Y-m-d'));
|
||||
config()->set('services.self_render.key', 'SECRET');
|
||||
// Р126, третья дверь: проксишная проба ходит только на разрешённую машину — называем vm.local.
|
||||
config()->set('services.self_render.allowed_hosts', ['vm.local']);
|
||||
Http::fake(['vm.local/proxy-check' => Http::response(['ok' => false, 'error' => 'down'], 200)]);
|
||||
Mail::fake();
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\External\LivenessReading;
|
||||
use App\Services\External\ProxyMarketProbe;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Р126, ТРЕТЬЯ ДВЕРЬ. Проба живости прокси шлёт ТОТ ЖЕ тайный ключ SELF_RENDER_KEY
|
||||
// заголовком X-Render-Key, что и обе уже закрытые двери рендера, — но адрес берёт из
|
||||
// ДРУГОЙ настройки, PROXY_MARKET_CHECK_URL. Опечатка в ней увела бы ключ чужому, а
|
||||
// http:// увёз бы его открытым текстом.
|
||||
//
|
||||
// 🔴 Эти проверки смотрят на РАБОТУ, а не на слова: главный признак «ключ не уехал» —
|
||||
// Http::assertNothingSent(), то есть запроса не было вовсе. Текст причины проверяется
|
||||
// отдельно и только там, где он что-то решает для человека у плитки.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Боевой адрес ручки прокси на виртуалке-рендере. Замер: config/services.php + ПИЛОТ.md 06.07.2026. */
|
||||
const PM_BOEVOY = 'https://51-250-1-97.sslip.io/proxy-check';
|
||||
|
||||
/** Тайный ключ в этих проверках. Ни одна причина закрытия не имеет права его назвать. */
|
||||
const PM_KLYUCH = 'sekret-renderа-123';
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('services.proxy_market.check_url', PM_BOEVOY);
|
||||
config()->set('services.proxy_market.expires_at', now()->addDays(30)->format('Y-m-d'));
|
||||
config()->set('services.proxy_market.amber_days', 7);
|
||||
config()->set('services.self_render.key', PM_KLYUCH);
|
||||
// 🔴 Список НЕ выдумываем — берём настоящее умолчание config/services.php, то самое,
|
||||
// что поедет на боевой.
|
||||
config()->set('services.self_render.allowed_hosts', (array) config('services.self_render.allowed_hosts'));
|
||||
});
|
||||
|
||||
function pmProba(): LivenessReading
|
||||
{
|
||||
return (new ProxyMarketProbe)->check();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Дверь ВООБЩЕ применена: чужой хост и http:// наружу не выпускают
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('чужой хост в PROXY_MARKET_CHECK_URL: запроса не было — ключ никуда не уехал', function () {
|
||||
Http::fake();
|
||||
config()->set('services.proxy_market.check_url', 'https://51-250-1-97.sslip.io.zloumyshlennik.ru/proxy-check');
|
||||
|
||||
expect(pmProba()->light)->toBe('grey');
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('опечатка в одной букве хоста закрывает дверь', function () {
|
||||
Http::fake();
|
||||
// sslip → ssIip (заглавная I вместо l) — ровно та опечатка, ради которой дверь заведена.
|
||||
config()->set('services.proxy_market.check_url', 'https://51-250-1-97.ssIip.io/proxy-check');
|
||||
|
||||
expect(pmProba()->light)->toBe('grey');
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('http на разрешённый хост: ключ не поехал открытым текстом', function () {
|
||||
Http::fake();
|
||||
config()->set('services.proxy_market.check_url', 'http://51-250-1-97.sslip.io/proxy-check');
|
||||
|
||||
$r = pmProba();
|
||||
|
||||
expect($r->light)->toBe('grey');
|
||||
expect($r->detail)->toContain('без шифрования');
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('адрес без схемы вовсе не принимается', function () {
|
||||
Http::fake();
|
||||
config()->set('services.proxy_market.check_url', '51-250-1-97.sslip.io/proxy-check');
|
||||
|
||||
expect(pmProba()->light)->toBe('grey');
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('пустой тайный ключ закрывает дверь ДО запроса, а не ловится ответом 401', function () {
|
||||
Http::fake();
|
||||
config()->set('services.self_render.key', '');
|
||||
|
||||
expect(pmProba()->light)->toBe('grey');
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. 🔴 СЕРЫЙ, А НЕ КРАСНЫЙ. Красный на этой плитке значит «прокси кончились» —
|
||||
// владелец побежит их продлевать. Соврать им нельзя.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('закрытая дверь красит серым, а живой отказ прокси — по-прежнему красным', function () {
|
||||
Http::fake();
|
||||
config()->set('services.proxy_market.check_url', 'http://51-250-1-97.sslip.io/proxy-check');
|
||||
expect(pmProba()->light)->toBe('grey');
|
||||
|
||||
// Та же плитка, но дверь открыта и прокси ответил отказом — красный сохранился.
|
||||
config()->set('services.proxy_market.check_url', PM_BOEVOY);
|
||||
Http::fake([PM_BOEVOY => Http::response(['ok' => false, 'error' => 'no proxies'], 200)]);
|
||||
expect(pmProba()->light)->toBe('red');
|
||||
});
|
||||
|
||||
it('вышедший срок остаётся красным — закрытая дверь его не съела', function () {
|
||||
config()->set('services.proxy_market.expires_at', now()->subDay()->format('Y-m-d'));
|
||||
Http::fake([PM_BOEVOY => Http::response(['ok' => true, 'ip' => '178.9.9.9'], 200)]);
|
||||
|
||||
expect(pmProba()->light)->toBe('red');
|
||||
});
|
||||
|
||||
it('причина закрытия называет настройку PROXY_MARKET_CHECK_URL и НЕ называет ключ', function () {
|
||||
Http::fake();
|
||||
config()->set('services.proxy_market.check_url', 'http://51-250-1-97.sslip.io/proxy-check');
|
||||
|
||||
$detail = pmProba()->detail;
|
||||
|
||||
expect($detail)->toContain('PROXY_MARKET_CHECK_URL');
|
||||
expect($detail)->not->toContain(PM_KLYUCH);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. 🔴 Живая плитка не сломана: боевой адрес проходит на НАСТОЯЩЕМ умолчании
|
||||
// настроек и тогда, когда приложение считает себя боевым.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('боевой адрес прокси открыт на настоящем умолчании и на боевом окружении', function () {
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
Http::fake([PM_BOEVOY => Http::response(['ok' => true, 'ip' => '178.9.9.9'], 200)]);
|
||||
|
||||
$r = pmProba();
|
||||
|
||||
expect($r->light)->toBe('green');
|
||||
expect($r->detail)->toContain('178.9.9.9');
|
||||
Http::assertSent(fn ($req) => $req->url() === PM_BOEVOY && $req->hasHeader('X-Render-Key', PM_KLYUCH));
|
||||
});
|
||||
|
||||
it('пробелы по краям адреса не мешают: ходим ровно по проверенному адресу', function () {
|
||||
config()->set('services.proxy_market.check_url', ' '.PM_BOEVOY.' ');
|
||||
Http::fake([PM_BOEVOY => Http::response(['ok' => true, 'ip' => '178.9.9.9'], 200)]);
|
||||
|
||||
expect(pmProba()->light)->toBe('green');
|
||||
Http::assertSent(fn ($req) => $req->url() === PM_BOEVOY);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 4. 🔴 Список разрешённых машин — ОБЩИЙ с рендером. Доказывается работой:
|
||||
// трогаем ТОЛЬКО services.self_render.allowed_hosts и смотрим, меняется ли
|
||||
// поведение проксишной пробы. Свой список у неё завести молча не получится.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('опустошение списка машин РЕНДЕРА закрывает и проксишную дверь', function () {
|
||||
Http::fake();
|
||||
config()->set('services.self_render.allowed_hosts', []);
|
||||
|
||||
expect(pmProba()->light)->toBe('grey');
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('машина, добавленная в список РЕНДЕРА, открывает и проксишную дверь', function () {
|
||||
config()->set('services.self_render.allowed_hosts', ['novaya-vm.example.ru']);
|
||||
config()->set('services.proxy_market.check_url', 'https://novaya-vm.example.ru/proxy-check');
|
||||
Http::fake(['novaya-vm.example.ru/*' => Http::response(['ok' => true, 'ip' => '178.9.9.9'], 200)]);
|
||||
|
||||
expect(pmProba()->light)->toBe('green');
|
||||
Http::assertSent(fn ($req) => $req->hasHeader('X-Render-Key', PM_KLYUCH));
|
||||
});
|
||||
|
||||
it('умолчание списка машин НЕ пусто — иначе плитка посерела бы в момент выката', function () {
|
||||
expect((array) config('services.self_render.allowed_hosts'))->not->toBeEmpty();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 5. 🔴 ФОРМЫ АДРЕСА, какие могут стоять в боевом .env — что увидит владелец.
|
||||
// Считаем на БОЕВОМ окружении: петля на проде послаблением не пользуется.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('формы адреса боевого .env дают ровно тот цвет, который принял владелец', function (string $adres, string $cvet) {
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
config()->set('services.proxy_market.check_url', $adres);
|
||||
Http::fake(['*' => Http::response(['ok' => true, 'ip' => '178.9.9.9'], 200)]);
|
||||
|
||||
expect(pmProba()->light)->toBe($cvet);
|
||||
})->with([
|
||||
'боевой https по имени' => [PM_BOEVOY, 'green'],
|
||||
'боевой https по адресу машины' => ['https://51.250.1.97/proxy-check', 'green'],
|
||||
'https с явным портом 443' => ['https://51-250-1-97.sslip.io:443/proxy-check', 'green'],
|
||||
'HTTPS заглавными' => ['HTTPS://51-250-1-97.sslip.io/proxy-check', 'green'],
|
||||
'https с пробелами по краям' => [' https://51-250-1-97.sslip.io/proxy-check ', 'green'],
|
||||
'петля по https' => ['https://127.0.0.1/proxy-check', 'green'],
|
||||
'🔴 http — ЦЕНА, принятая владельцем' => ['http://51-250-1-97.sslip.io/proxy-check', 'grey'],
|
||||
'🔴 http на петлю, но окружение боевое' => ['http://127.0.0.1/proxy-check', 'grey'],
|
||||
'опечатка в имени машины' => ['https://51-250-1-97.ssIip.io/proxy-check', 'grey'],
|
||||
'чужой хост с боевым именем внутри' => ['https://51-250-1-97.sslip.io.chuzhoy.ru/proxy-check', 'grey'],
|
||||
'вовсе без схемы' => ['51-250-1-97.sslip.io/proxy-check', 'grey'],
|
||||
'пусто' => ['', 'grey'],
|
||||
]);
|
||||
@@ -12,6 +12,11 @@ beforeEach(function () {
|
||||
config()->set('services.proxy_market.check_url', 'https://vm.local/proxy-check');
|
||||
config()->set('services.proxy_market.amber_days', 7);
|
||||
config()->set('services.self_render.key', 'SECRET');
|
||||
// 🔴 Р126, третья дверь (07.08.2026): проба теперь ходит только на разрешённую машину и
|
||||
// только по https. Выдуманный в этих проверках vm.local надо назвать вслух — ровно так же,
|
||||
// как боевой адрес назван в config/services.php. Сама дверь проверяется отдельно
|
||||
// (ProxyMarketProbeDverTest), тут её условия просто выполнены.
|
||||
config()->set('services.self_render.allowed_hosts', ['vm.local']);
|
||||
});
|
||||
|
||||
it('серый, когда check_url или срок не заданы', function () {
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
| `zagotovki-slepki.sha256` | слепок каждой заготовки — по нему видно не только пропажу, но и порчу |
|
||||
| `lena-zagotovki.conf` | копия правила, которое возвращает заготовки в `/tmp` при загрузке — эталон |
|
||||
| `proverka-zagotovok.sh` | сторож заготовок: хранилище, машина, правило и живой опыт воссоздания |
|
||||
| `sutochnyy-obhod.sh` | суточный обход: машина сама гоняет своих сторожей и складывает итог |
|
||||
| `sutochnyy-obhod.cron` | копия расписания обхода с сервера — эталон |
|
||||
| `proverka-sutochnogo-obhoda.sh` | сторож самого обхода — тот, кто следит за следящим |
|
||||
| `priyomnik.py` | приёмник заданий от портала: три ручки, память знаков на сутки, ключ |
|
||||
| `proba-priyomnika.py` | проба приёмника подставным набором, **без звонка** |
|
||||
| `lena-priyomnik.service` | копия службы приёмника — эталон для сверки (на сервере **ещё нет**) |
|
||||
|
||||
## Ключ
|
||||
|
||||
@@ -630,6 +636,531 @@ grep '^[0-9a-f]' bots/lena-golos/zagotovki-slepki.sha256 \
|
||||
|
||||
Обе команды обязаны не напечатать **ничего**.
|
||||
|
||||
## Суточный обход: машина проверяет себя сама, а портал за этим следит
|
||||
|
||||
### Чем эта затея отличается от всего, что выше
|
||||
|
||||
Всё, что описано выше, — сторожа. Они умеют показать, жива ли уборка записей
|
||||
телефонных разговоров с живыми людьми. Но до 06.08.2026 их запускал **только
|
||||
человек, который про них помнил.** А значит — почти никогда.
|
||||
|
||||
Вред от этого не теоретический. Уборка умирает молча: расписание сломается,
|
||||
скрипт исчезнет, права папки съедут — и голоса чужих людей копятся месяцами,
|
||||
пока кто-нибудь случайно не прогонит сторожа рукой. Это персональные данные и
|
||||
закон.
|
||||
|
||||
С 06.08.2026 обход ходит сам:
|
||||
|
||||
```text
|
||||
машина 51.250.1.97 портал liderra.ru
|
||||
────────────────── ─────────────────
|
||||
05:20 МСК, /etc/cron.d/lena-sutochnyy-obhod
|
||||
└─ sutochnyy-obhod.sh
|
||||
├─ гоняет ВСЕХ сторожей из своей папки
|
||||
├─ кладёт итог файлом otchyot-storozhey.json
|
||||
└─ стучится с ним в портал ──────────► POST /api/webhook/lena-storozha
|
||||
└─ кладёт отметку
|
||||
scheduler_heartbeats
|
||||
'lena:obhod-storozhey'
|
||||
06:10 МСК lena:proverka-obhoda
|
||||
├─ итог протух? ─┐
|
||||
├─ сторож красный? ├─► письмо
|
||||
└─ иначе молчит ───┘
|
||||
```
|
||||
|
||||
### 🔴 Главное, что здесь легко сломать по невнимательности
|
||||
|
||||
**Возраст итога считается по дате ВНУТРИ итога, а не по времени получения.**
|
||||
Дату сборки ставит сама машина в момент обхода, поле называется так:
|
||||
|
||||
```text
|
||||
"sobrano_v": "2026-08-06T18:42:52Z"
|
||||
```
|
||||
|
||||
Отправь кто-нибудь вчерашний файл сегодня — портал увидит вчерашнюю дату и
|
||||
заругается, хотя стук дошёл минуту назад. Замените в приёмщике эту дату на
|
||||
«сейчас» — и вся затея превратится в украшение: всё будет вечно свежим.
|
||||
|
||||
**Зелёный итог недельной давности хуже красного сегодняшнего.** Проверка
|
||||
возраста работает ВСЕГДА, даже когда внутри итога всё зелено. Это не
|
||||
перестраховка: именно так выглядит тихая смерть машины.
|
||||
|
||||
**Письма при здоровье нет.** Ежедневное «всё хорошо» перестают читать через
|
||||
неделю, а вместе с ним перестают замечать и настоящую тревогу.
|
||||
|
||||
### Кто зовёт каждую часть
|
||||
|
||||
```text
|
||||
обход на машине ← расписание /etc/cron.d/lena-sutochnyy-obhod
|
||||
не станет зовущего → итог перестанет собираться,
|
||||
портал заругается по ВОЗРАСТУ
|
||||
|
||||
приём итога в портале ← сама машина, curl из обхода
|
||||
не станет зовущего → стук не дойдёт,
|
||||
портал заругается по ВОЗРАСТУ
|
||||
|
||||
ежедневная проверка ← app/routes/console.php, 06:10 МСК
|
||||
не станет зовущего → тревога замолчит, см. строку ниже
|
||||
|
||||
она же КАК ЗАДАЧА ← записана в SchedulerHeartbeatTracker,
|
||||
за ней следит штатный часовой
|
||||
scheduler:check-heartbeats
|
||||
не станет зовущего → присмотр остался бы без присмотра
|
||||
```
|
||||
|
||||
### Что лежит на машине
|
||||
|
||||
Папка `/home/ubuntu/lena-golos/` — **машинная копия** двенадцати эталонов из
|
||||
`bots/lena-golos/`. Обход и сторожа читают эталоны оттуда: своего ssh-ключа у
|
||||
машины нет и заводить его нельзя (ключ от самой себя на машине с голосами
|
||||
живых людей — дыра, а не удобство).
|
||||
|
||||
🔴 Отсюда вылезает круговая порука: обход на машине сверяет живые файлы с
|
||||
**машинной копией**, а не с хранилищем. Разойдись копия с хранилищем — обход
|
||||
сравнил бы копию сам с собой и промолчал. Разрывает это только взгляд снаружи:
|
||||
сторож обхода ходит **с рабочего места по ssh** и сверяет обе стороны
|
||||
побайтово.
|
||||
|
||||
Рядом лежат четыре рабочих файла, которых в хранилище нет и не будет:
|
||||
|
||||
```text
|
||||
otchyot-storozhey.json последний итог обхода
|
||||
sutochnyy-obhod.log подробный вывод сторожей
|
||||
ОБНУЛЯЕТСЯ каждым обходом
|
||||
sutochnyy-obhod.log.otvet что ответил портал на последний стук
|
||||
nastroyki-obhoda адрес портала и тайное слово, права 600
|
||||
```
|
||||
|
||||
Журнал обнуляется не для аккуратности. В него ложится вывод сторожей, а там
|
||||
строки вида «челове[к]: 5 знаков, 1 слов» — сегодня это **мера**, а не слова.
|
||||
Но место, куда что-то похожее на речь ложится без срока и без уборки, заводить
|
||||
нельзя: именно такие места здесь и вылавливают. Обнуление даёт потолок — в
|
||||
журнале живёт ровно один, последний обход.
|
||||
|
||||
### Как проверить, что обход жив
|
||||
|
||||
```bash
|
||||
bash bots/lena-golos/proverka-sutochnogo-obhoda.sh
|
||||
```
|
||||
|
||||
42 проверки. Он смотрит расписание, побайтовое совпадение всех двенадцати
|
||||
эталонов с машиной, состав папки (лишний `proverka-*.sh` обход подхватит и
|
||||
погонит — значит лишних быть не должно), свежесть итога, его поля, и гоняет
|
||||
сам обход на **подставных** сторожах во временной папке.
|
||||
|
||||
🔴 Подставные сторожа — нарочно. Настоящих на машине руками не ломаем
|
||||
(решение владельца Р94). **Чего такая подмена не доказывает:** что настоящий
|
||||
сторож, увидев настоящую поломку уборки, покраснеет. Это дело самих сторожей,
|
||||
и каждого показывали красным в своём круге. Здесь доказывается только
|
||||
проводка: обход замечает красноту и доносит её до итога поимённо.
|
||||
|
||||
### Что ещё не доделано
|
||||
|
||||
**Портальная половина не выкачена.** Пока это так, файл настроек отправки на
|
||||
машине пуст, обход пишет итог файлом и говорит в журнал «отправка не
|
||||
настроена», а сторож обхода честно краснеет одной проверкой из 42 — «дорога до
|
||||
портала НЕ настроена». Это не поломка, это незавершённый выкат. Когда портал
|
||||
выкатят, четыре шага:
|
||||
|
||||
```bash
|
||||
# 1. на портале, в окружении (слово взять из генератора, не короче 32 знаков)
|
||||
LENA_STOROZHA_SLOVO=...
|
||||
LENA_STOROZHA_ADRESA=51.250.1.97
|
||||
|
||||
# 2. на машине то же слово и адрес портала, права 600
|
||||
sudo -u ubuntu tee /home/ubuntu/lena-golos/nastroyki-obhoda <<'EOF'
|
||||
PORTAL_URL=https://liderra.ru/api/webhook/lena-storozha
|
||||
TAYNOE_SLOVO=...
|
||||
EOF
|
||||
chmod 600 /home/ubuntu/lena-golos/nastroyki-obhoda
|
||||
|
||||
# 3. прогнать рукой и увидеть в журнале «портал принял итог, ответ 202»
|
||||
bash /home/ubuntu/lena-golos/sutochnyy-obhod.sh
|
||||
|
||||
# 4. прогнать сторожа обхода — он должен стать зелёным целиком
|
||||
bash bots/lena-golos/proverka-sutochnogo-obhoda.sh
|
||||
```
|
||||
|
||||
🔴 **Тайного слова в хранилище нет и быть не должно.** Сторож обхода это
|
||||
проверяет отдельной проверкой.
|
||||
|
||||
### Прибавили сторожа — скажите порталу
|
||||
|
||||
Обход ищет сторожей **списком файлов** в своей папке: положили новый
|
||||
`proverka-*.sh` — он попадёт в обход сам, и число в итоге вырастет. Портал
|
||||
сверит это число со своей настройкой (по умолчанию 3) и заругается, пока
|
||||
человек не подтвердит прибавление руками. Так «сторожей стало четыре, а стучит
|
||||
по-прежнему три» перестаёт быть возможным молча.
|
||||
|
||||
```text
|
||||
сегодня в обход входят трое:
|
||||
proverka-raspisaniya
|
||||
proverka-tekstovogo-sleda
|
||||
proverka-chistki
|
||||
|
||||
четвёртый, proverka-zagotovok, В ОБХОД НЕ ВХОДИТ:
|
||||
он сторожит заготовки подбора голоса, а не уборку чужих
|
||||
записей — то есть не ту беду, ради которой обход заведён
|
||||
|
||||
захотите включить — три шага:
|
||||
1. положить его в /home/ubuntu/lena-golos/
|
||||
2. поднять LENA_STOROZHA_SKOLKO до 4
|
||||
3. дописать его в список эталонов сторожа обхода
|
||||
```
|
||||
|
||||
### Прибавили поле в итог — тоже скажите порталу
|
||||
|
||||
С 06.08.2026 портал проверяет не только числа в итоге, но и **связность** того,
|
||||
что принял. Три вещи, о которых стоит знать, прежде чем править вывод обхода:
|
||||
|
||||
```text
|
||||
1. Длина списка обязана сойтись с объявленным числом.
|
||||
Сказали "storozhey_vsego": 3, а строк в списке две —
|
||||
портал заругается. Раньше двое сторожей исчезали молча.
|
||||
|
||||
2. Объявленное число красных обязано сойтись со списком.
|
||||
Поле "storozhey_krasnyh" портал теперь ЧИТАЕТ и сверяет
|
||||
со своим подсчётом по строкам. Считает он красным ровно
|
||||
по тому же правилу, что и обход: код не ноль ИЛИ провалов
|
||||
не ноль. Зелёного сторожа с нулём проверок обход красным
|
||||
не считает — и портал в этом счёте тоже не считает.
|
||||
|
||||
3. Поля, которых портал не знает, — беда, а не мелочь.
|
||||
Портал знает восемь полей итога:
|
||||
shema, sobrano_v, mashina, dlilos_ms,
|
||||
storozhey_vsego, storozhey_krasnyh,
|
||||
slepok_etalonov, storozha
|
||||
и четыре поля строки сторожа:
|
||||
imya, kod, proverok, provalov
|
||||
Всё остальное он назовёт новостью и покраснеет.
|
||||
```
|
||||
|
||||
🔴 **Цена этого названа честно:** прибавите в итог безобидное поле — портал
|
||||
станет красным, пока человек не впишет его в список знакомых полей на
|
||||
портальной стороне. Это сделано нарочно, той же рукой, что и число ожидаемых
|
||||
сторожей: забытое поле должно оказаться **под** защитой, а не мимо неё. Правится
|
||||
одной строкой в списке знакомых полей в двери приёма итога.
|
||||
|
||||
## Приёмник заданий от портала — роботная половина шва
|
||||
|
||||
### Что это и почему без него портал звонит в пустоту
|
||||
|
||||
06.08.2026 построили портальную половину шва: портал научился сказать роботу
|
||||
«позвони туда-то». На роботной стороне **не было никого** — приёмника заданий не
|
||||
существовало. Теперь есть: файл `priyomnik.py`.
|
||||
|
||||
🔴 На том конце живой человек и деньги клиента. Набранный номер отыграть нельзя,
|
||||
поэтому всё устройство приёмника вращается вокруг одного: **никто не должен
|
||||
получить второй звонок из-за того, что ответ потерялся по дороге.**
|
||||
|
||||
### Три ручки и как портал читает ответ
|
||||
|
||||
| ручка | что просят | что в теле |
|
||||
|---|---|---|
|
||||
| `POST /pozvonit` | позвонить человеку | знак задания, номер, что ещё сказать роботу |
|
||||
| `POST /sostoyanie` | что с разговором | номер звонка, который выдал робот |
|
||||
| `POST /ostanovit` | прекратить разговор | номер звонка и причина |
|
||||
|
||||
Заголовки у всех трёх одни и те же:
|
||||
|
||||
```text
|
||||
X-Obzvon-Key: тайный ключ
|
||||
X-Obzvon-Zadanie: опознавательная метка
|
||||
Idempotency-Key: та же метка (дублируется нарочно: посредник, срезающий
|
||||
нестандартные заголовки, не должен молча обезоружить защиту)
|
||||
```
|
||||
|
||||
🔴 **Главное — как портал читает ответ.** Это его код, а не наше пожелание:
|
||||
|
||||
| ответ приёмника | что думает портал |
|
||||
|---|---|
|
||||
| 200 и в теле `ok` = истина | принял, звонок пошёл |
|
||||
| любой 4xx | **звонка НЕ БЫЛО**, повторить можно |
|
||||
| любой 5xx | **звонок МОГ состояться**, вслепую не повторять |
|
||||
| 200 без слова `ok` | не знаю |
|
||||
| молчание, обрыв | не знаю |
|
||||
|
||||
🔴🔴 Отсюда железное правило приёмника: **отвечать 4xx можно только когда точно
|
||||
известно, что набор не начинался.** Ответ 4xx после начала набора портал прочтёт
|
||||
как «звонка не было», повторит, и живому человеку позвонят дважды. Сомневаешься —
|
||||
5xx. В коде каждый 4xx на ручке звонка стоит строго **до** обращения к набору;
|
||||
единственное исключение — когда сам набор отдельным полем поклялся, что не
|
||||
начинался.
|
||||
|
||||
### Опознавательный знак и память на сутки — решение владельца Р124
|
||||
|
||||
Знак задания придумывает **портал**: клиент, кампания, номер и число попытки,
|
||||
причём номер входит туда только хешем. Одна и та же попытка при любом числе
|
||||
повторов даёт один знак, разные попытки — разные.
|
||||
|
||||
🔴 **Память знаков лежит на диске** (файл SQLite рядом с роботом) и **переживает
|
||||
перезапуск**. Держать её в памяти работающей программы было бы защитой ровно до
|
||||
первого сбоя — то есть она исчезала бы именно тогда, когда нужнее всего.
|
||||
|
||||
🔴 **И не в** `/tmp`: эта папка вычищается целиком при каждой перезагрузке
|
||||
машины, и защита от двойного звонка умирала бы молча в день перезагрузки.
|
||||
|
||||
Что там лежит: знак, отпечаток тела задания (см. ниже), выданный номер звонка,
|
||||
код и тело нашего ответа. Ни телефона, ни имени, ни слов человека там нет.
|
||||
|
||||
**Цена этого решения названа честно.** Времени в знаке нет. Значит перезапуск той
|
||||
же кампании по тому же номеру **в пределах суток** робот сочтёт повтором и
|
||||
звонить не станет — вернёт прежний ответ. Через сутки тот же знак снова законен.
|
||||
Так решил владелец, зная это.
|
||||
|
||||
🔴 Строки старше суток удаляются при первой же записи. Это **не** нарушает Р94
|
||||
(«на машине робота ничего не удалять»): Р94 про накопленный след разговоров живых
|
||||
людей, а тут наша собственная расписка, которой Р124 назначило срок жизни ровно в
|
||||
сутки. Не удалять её значило бы не исполнить Р124.
|
||||
|
||||
### Два одинаковых знака почти одновременно
|
||||
|
||||
Самый злой случай: первое задание **ещё в работе**, ответа пока не существует, и
|
||||
приходит второе с тем же знаком. Врать нельзя ни в какую сторону — ни «звонка не
|
||||
было» (первый поток, может, уже набирает), ни «принял, вот номер звонка»
|
||||
(которого ещё нет). Поэтому знак **резервируется до набора**, одной неделимой
|
||||
записью, а тот, кто резерв не получил, **не набирает** и отвечает **5xx «не
|
||||
знаю»**. Портал вслепую не повторит.
|
||||
|
||||
🪤 Следствие: если робот упал между резервом и ответом, запись останется «в
|
||||
работе», и повторы этого знака будут получать 5xx до конца суток. Это не
|
||||
поломка, а честное «не знаю»: мы вправду не знаем, успел ли упавший процесс
|
||||
набрать. Само рассосётся через сутки.
|
||||
|
||||
### 🔴 Знак тот же, а тело задания другое — замерено владельцем 07.08.2026
|
||||
|
||||
Вся защита выше стояла на одном допущении: **повтор знака = повтор задания.**
|
||||
Владелец замерил годный по форме случай мимо этого допущения: знак тот же, а
|
||||
телефон в теле **другой**. Приёмник считал это повтором, второму человеку не
|
||||
звонил вовсе — молча, без единого признака, — и отдавал порталу номер звонка
|
||||
**первого** задания. Портал привязал бы итог чужого разговора к другому человеку.
|
||||
|
||||
Случайно такое не выйдет: знак — отпечаток четырёх доводов сразу. Но портал
|
||||
принимает знак и телефон **двумя отдельными доводами**, и ничто не заставляет их
|
||||
сойтись. Работнику очереди, который посчитает знак один раз и станет перебирать
|
||||
номера, достаётся ровно этот случай.
|
||||
|
||||
Проверить, правильно ли сложен знак, приёмник не может — он не знает, из чего его
|
||||
складывают. Зато он может заметить, что **тот же знак пришёл с другим телом**.
|
||||
Поэтому вместе со знаком запоминается **отпечаток тела**: тот же — вправду повтор,
|
||||
другой — ошибка портала, о которой молчать нельзя.
|
||||
|
||||
| что пришло | что делает приёмник |
|
||||
|---|---|
|
||||
| знак тот же, тело то же | повтор: набора нет, отдаём прежний ответ |
|
||||
| знак тот же, тело другое | **422**, набора нет, чужой номер звонка не отдаём |
|
||||
| знак тот же, тело то же плюс новое поле | тоже **422**: это другое тело |
|
||||
| знак тот же, поля переставлены местами | повтор: порядок полей ничего не значит |
|
||||
|
||||
🔴 **Почему 422, то есть 4xx.** Это правда: по второму телу мы не набирали
|
||||
ничего. Соблазн ответить 5xx был — повторять-то бессмысленно, пока портал не
|
||||
починят. Но 5xx значит «звонок мог состояться», портал пометил бы человека как
|
||||
«может быть, звонили» и не позвонил бы ему **никогда**. Это ровно та беда, что
|
||||
здесь лечится, только навсегда. Пустой повтор стоит одного запроса; молчаливый
|
||||
пропуск человека стоит человека.
|
||||
|
||||
🔴 **Почему код отдельный, а не общий с 409** «набор не начинался». Портал на 4xx
|
||||
пишет в журнал **только код** — причину из тела он туда не кладёт. Значит код и
|
||||
есть всё сообщение, и два разных случая обязаны иметь разные коды.
|
||||
|
||||
🔴 **Список полей отпечатка считается вычитанием, а не перечислением** — той же
|
||||
рукой, что и в двери приёма итога. Перечисли мы поля — новое поле, о котором
|
||||
завтра забудут, оказалось бы **мимо** защиты. Вычитанием оно само оказывается
|
||||
**под** защитой. Вычтен ровно один знак задания: это ключ памяти, а ключ не
|
||||
может быть частью своего же значения.
|
||||
|
||||
🔴 **Отпечаток снят под тайным ключом, а не голым хешем.** Голый хеш от тела, в
|
||||
котором остался один телефон, перебирается за минуты — номеров всего одиннадцать
|
||||
знаков. Ключ лежит отдельным файлом с правами 600 и в память знаков не попадает:
|
||||
унесённый файл памяти не даёт по отпечатку ни телефона, ни имени, ни слов
|
||||
скрипта.
|
||||
|
||||
**Цена названа честно:**
|
||||
|
||||
- тело сравнивается **дословно**. Телефон, записанный в первый раз с плюсом и
|
||||
скобками, а в повторе одними цифрами, — это разное тело, хотя человек тот же
|
||||
(знак портал считает от одних цифр). Приёмник телефонов не разбирает вовсе и
|
||||
разбирать не начнёт — это работа портала. Случись такое, приёмник заругается
|
||||
вслух, названным кодом, и это верный признак, что портал шлёт номер не тем
|
||||
видом, каким его хешировал;
|
||||
- **лишнее поле — тоже расхождение.** Выкати портал новую версию, добавившую
|
||||
поле, между первой попыткой и повтором — честный повтор получит 422. Это
|
||||
громкий и чинимый отказ вместо тихого звонка не тому. Рассосётся через сутки;
|
||||
- **смена тайного ключа обнуляет отпечатки** на сутки вперёд;
|
||||
- **расписки, заведённые до этой починки, отпечатка не имеют** и сутки после
|
||||
выката ведут себя как раньше.
|
||||
|
||||
🔴 На расхождении память **не трогается вовсе**: расписка первого задания цела,
|
||||
бронь на месте, ответ не переписан. Иначе лечение стоило бы второго звонка тому,
|
||||
кому уже позвонили. И второму заданию мы тоже **не звоним**: какое из двух тел
|
||||
настоящее — неизвестно, а звонить живому человеку по сломанному уговору нельзя.
|
||||
|
||||
🪤 **Это заплата на роботной стороне, а не починка причины.** Причина — на
|
||||
портале: он даёт передавать знак и телефон врозь. Пока это так, дверь останется
|
||||
нужна, и снимать её после починки портала не надо — она дешёвая и ловит ошибку,
|
||||
которую иначе никто не заметит.
|
||||
|
||||
### 🔴 Память применяется только к ручке звонка
|
||||
|
||||
Портал шлёт `Idempotency-Key` на всех трёх ручках, но кладёт туда **разное**:
|
||||
на звонке это знак задания, а на двух других — **номер звонка**. Прими мы его за
|
||||
ключ памяти везде — второй вопрос «что с разговором?» получил бы в ответ
|
||||
запомненное состояние вместо нынешнего, и портал вечно видел бы разговор таким,
|
||||
каким он был в первую секунду. Поэтому память знаков работает **только** на
|
||||
`pozvonit`.
|
||||
|
||||
🪤 Говорю точно, чтобы не приукрасить: на двух других ручках метка из заголовка
|
||||
служит **запасным** источником номера звонка, если тела нет или в нём номера не
|
||||
оказалось. Сверки «тело против заголовка» там нет — она есть только на ручке
|
||||
звонка, где расхождение стоит второго набора.
|
||||
|
||||
### Кто кого зовёт
|
||||
|
||||
| часть | кто её зовёт | не станет зовущего |
|
||||
|---|---|---|
|
||||
| ручка `pozvonit` | портал `liderra.ru`, класс `ObzvonClient` | задания не доедут, портал увидит «робот молчит» = «не знаю» |
|
||||
| ручки `sostoyanie` и `ostanovit` | тот же портал, по номеру звонка | портал не узнает, чем кончился разговор |
|
||||
| сам приёмник | служба `lena-priyomnik` (systemd) | все три ручки замолчат разом |
|
||||
| набор номера | **сам приёмник**, через `Nabornik` | звонков не будет; сегодня зовущего **нет вовсе**, см. ниже |
|
||||
| отчёт о разговоре | **никто** — второй половины З-0.6 ещё нет | портал не узнает исход, деньги не спишутся |
|
||||
|
||||
```text
|
||||
портал liderra.ru машина робота 51.250.1.97
|
||||
───────────────── ─────────────────────────
|
||||
ObzvonClient::pozvonit
|
||||
└─ POST /pozvonit ──────────────────► priyomnik.py, ручка pozvonit
|
||||
X-Obzvon-Key, X-Obzvon-Zadanie ├─ ключ верен? нет → 401
|
||||
Idempotency-Key ├─ знак уже был? да → тот же ответ
|
||||
│ а тело другое? → 422, набора нет
|
||||
├─ занять знак + отпечаток тела
|
||||
│ (одной неделимой записью)
|
||||
├─ Nabornik.nabrat()
|
||||
│ сегодня: НЕ НАСТРОЕН → 4xx
|
||||
└─ запомнить ответ на сутки
|
||||
◄──────────── 200 {"ok":true,"call_id":...} либо 4xx либо 5xx
|
||||
|
||||
ObzvonClient::sostoyanie ──────────────► ручка sostoyanie (память знаков НЕ
|
||||
ObzvonClient::ostanovit ──────────────► ручка ostanovit применяется)
|
||||
```
|
||||
|
||||
### Чем поднят и сколько стоит — решение владельца Р84
|
||||
|
||||
Машина делит железо с рендером **боевого** «Поиска клиентов». Поэтому в приёмнике
|
||||
**ни одной сторонней библиотеки**: только то, что уже есть в самом Python.
|
||||
|
||||
| что | сколько |
|
||||
|---|---|
|
||||
| сторонних библиотек | 0 — ни Flask, ни FastAPI, ни uvicorn, ни отдельного цикла событий |
|
||||
| процессов | 1, потоки создаются на запрос и гибнут с ним |
|
||||
| одновременных запросов | не больше 8, сверх — 429 (то есть честное «звонка не было») |
|
||||
| тело запроса | не длиннее 64 КБ, сверх — 413 |
|
||||
| обращений к базе на задание | 7 — замерено пробой |
|
||||
| пик памяти на 200 заданиях подряд | 0,17 МБ — замерено пробой |
|
||||
| в покое | спит на приёме соединения, не ест ни процессора, ни диска |
|
||||
|
||||
Служба вдобавок понижена по `Nice` и поставлена в `IOSchedulingClass=idle` — то
|
||||
есть уступает дорогу боевому рендеру, а не соревнуется с ним.
|
||||
|
||||
### Личные данные
|
||||
|
||||
В теле задания приезжает **номер телефона живого человека**, а вместе с ним имя
|
||||
собеседника и текст скрипта. Это данные ровно того же рода, что и слова из трубки,
|
||||
которые 06.08.2026 убрали из журнала моста.
|
||||
|
||||
В журнал приёмника пишутся **только**: имя ручки, опознавательный знак (телефон в
|
||||
нём необратим), номер звонка, код ответа и короткая причина. Тела запроса в
|
||||
журнале нет никогда — целиком, а не «по возможности». Тайного ключа тоже нет.
|
||||
|
||||
🔴 Журнал приёмника идёт в `journald`, а **не** в свой файл. У обеих Лен стоит
|
||||
`StandardOutput=append:`, и такой файл приходится отдельно вращать, иначе
|
||||
заводится место, куда что-то пишется без срока и без уборки — ровно тот класс
|
||||
беды, который здесь и вылавливают. У приёмника такого файла нет вовсе.
|
||||
|
||||
```bash
|
||||
ssh ubuntu@51.250.1.97 "journalctl -u lena-priyomnik -n 200"
|
||||
```
|
||||
|
||||
### Как проверить, что приёмник цел
|
||||
|
||||
```bash
|
||||
python bots/lena-golos/proba-priyomnika.py
|
||||
```
|
||||
|
||||
**81 проверка.** Ни сети наружу, ни телефонии, ни звонка: подставной набор
|
||||
номеров не набирает, а **считает, сколько раз его позвали** — «человеку позвонили
|
||||
дважды» выглядит здесь как «набор позвали два раза». Среди проверок:
|
||||
|
||||
- один и тот же знак дважды — набор позван **ровно один раз**, и повтору отдан
|
||||
тот же номер звонка;
|
||||
- граница суток с обеих сторон: через 23 часа ещё повтор, через 25 — уже законное
|
||||
новое задание;
|
||||
- **перезапуск робота**: память закрыли, подняли заново — повтор всё равно узнан;
|
||||
- **гонка**: два одинаковых знака в двух потоках дают один набор, второму —
|
||||
строго 5xx, и отдельной проверкой доказано, что 4xx там не выдан;
|
||||
- набор упал исключением — ответ 5xx, а не 4xx, и повтор второй раз не набирает;
|
||||
- пустой ключ в настройке закрывает дверь **совсем**, а не пускает всех;
|
||||
- проверка ключа вправду **зовёт** `hmac.compare_digest` — смотрится в
|
||||
скомпилированный код, а не в текст файла;
|
||||
- ни телефона, ни имени, ни скрипта, ни ключа — ни в журнале, ни в файле памяти;
|
||||
- настоящий HTTP на `127.0.0.1`: коды, заголовки, `GET` = 405, толстое тело = 413.
|
||||
|
||||
🔴 **Чего проба не доказывает:** что робот вправду позвонит (настоящего набора
|
||||
нет), что портал и приёмник сойдутся на живой сети, и что служба поднята на
|
||||
машине свежим текстом. Первое — отдельная задача с окном на живые звонки, второе
|
||||
и третье — дело выката и сторожа сверки.
|
||||
|
||||
### 🔴 Чего сегодня нет — порядок выката
|
||||
|
||||
**Приёмник на машину не выкачен, и звонить он пока не умеет.** Набор номера —
|
||||
отдельная вещь (`Nabornik`), и сегодня в ней стоит `NabornikNeNastroen`, который
|
||||
честно отказывает: на `pozvonit` приёмник отвечает 4xx «набор не настроен», то
|
||||
есть говорит порталу правду — звонка не было.
|
||||
|
||||
🔴 Соблазн был сделать «принял и ничего не делаю»: тогда портальная сторона
|
||||
выглядела бы работающей. Это худшее из возможного — портал считал бы звонки,
|
||||
которых нет, и человек в отчёте увидел бы работу, которой не было.
|
||||
|
||||
Что нужно, чтобы приёмник заработал по-настоящему:
|
||||
|
||||
```bash
|
||||
# 1. положить приёмник рядом с мостом
|
||||
scp bots/lena-golos/priyomnik.py ubuntu@51.250.1.97:/home/ubuntu/lena-inworld/
|
||||
|
||||
# 2. тайное слово (не короче 32 знаков, из генератора), права 600.
|
||||
# То же слово кладётся на портале в OBZVON_KEY.
|
||||
ssh ubuntu@51.250.1.97 "umask 077 && printf '%s' '...' > /home/ubuntu/lena-inworld/.kluch-priyomnik"
|
||||
|
||||
# 3. служба
|
||||
scp bots/lena-golos/lena-priyomnik.service ubuntu@51.250.1.97:/tmp/novaya-sluzhba
|
||||
ssh ubuntu@51.250.1.97 "sudo install -m 644 -o root -g root /tmp/novaya-sluzhba /etc/systemd/system/lena-priyomnik.service && rm -f /tmp/novaya-sluzhba && sudo systemctl daemon-reload && sudo systemctl enable --now lena-priyomnik"
|
||||
|
||||
# 4. на портале: адрес робота и тот же ключ
|
||||
# OBZVON_ENDPOINT=http://51.250.1.97:8095
|
||||
# OBZVON_KEY=... (хост уже стоит в OBZVON_ALLOWED_HOSTS)
|
||||
|
||||
# 5. убедиться, что приёмник цел
|
||||
python bots/lena-golos/proba-priyomnika.py
|
||||
```
|
||||
|
||||
🪤 Если рабочее дерево на Windows подставило `\r`, файлы поедут на сервер с
|
||||
лишними символами. Снимать при переносе: `tr -d '\r' < файл`.
|
||||
|
||||
**Открытые хвосты, которые этот заход не закрывает и не притворяется, что
|
||||
закрывает:**
|
||||
|
||||
- **набора нет** — робот принимает задание и честно отказывается звонить;
|
||||
- **отчёта о разговоре нет** — это вторая половина З-0.6, живёт в `most.py`;
|
||||
- **порт 8095 смотрит наружу голым HTTP** — ключ ездит по сети открытым текстом.
|
||||
Пока приёмник не выкачен, это ничего не стоит; при выкате либо TLS, либо
|
||||
доступ только с адреса портала, и решать это надо **до** первого живого ключа;
|
||||
- **своего сторожа сверки с машиной у приёмника нет** — он появится вместе с
|
||||
выкатом, как у моста;
|
||||
- **в суточный обход приёмник не входит**: обход зовёт файлы `proverka-*.sh` из
|
||||
своей папки, а проба приёмника — не сторож уборки чужих записей. Захотите
|
||||
включить — это три шага и подтверждение числа сторожей на портале, см. выше.
|
||||
|
||||
<!-- cspell:ignore priyomnik pozvonit sostoyanie ostanovit nabornik Nabornik Obzvon Nastroen zadanie hmac digest journald uvicorn fastapi systemctl daemon novaya sluzhba umask kluch scp znak pamyat zadaniy predel odnovremennyh -->
|
||||
|
||||
## Как сверить копию настройки с сервером
|
||||
|
||||
`extensions-lena.conf` — копия `/etc/asterisk/extensions.conf` с сервера
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Эталон службы приёмника заданий. Копия того, что должно лежать на сервере
|
||||
# liderra-render (51.250.1.97) в /etc/systemd/system/lena-priyomnik.service.
|
||||
#
|
||||
# 🔴 НА 07.08.2026 НА СЕРВЕРЕ ЭТОГО ФАЙЛА НЕТ. Приёмник построен как эталон в
|
||||
# хранилище, выкат — отдельная работа и не сегодня. Сверять с сервером
|
||||
# сегодня нечего, и это не поломка.
|
||||
#
|
||||
# 🔴 ЖУРНАЛ ИДЁТ В journald, а НЕ в файл — и это решение, а не умолчание.
|
||||
# У обеих Лен стоит `StandardOutput=append:` в свой файл, и такой файл
|
||||
# приходится отдельно вращать (см. tekstovyy-sled.logrotate), иначе
|
||||
# заводится место, куда что-то пишется без срока и без уборки — ровно тот
|
||||
# класс беды, который здесь и вылавливают. У приёмника такого файла нет
|
||||
# вовсе: journald сам держит потолок размера. Смотреть журнал так:
|
||||
# journalctl -u lena-priyomnik -n 200
|
||||
#
|
||||
# 🔴 Nice и IOSchedulingClass — решение владельца Р84: машина делит железо с
|
||||
# рендером БОЕВОГО «Поиска клиентов», и приёмник обязан уступать ему дорогу,
|
||||
# а не соревноваться за процессор и диск.
|
||||
|
||||
[Unit]
|
||||
Description=Лена: приёмник заданий обзвона от портала
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
Group=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/lena-inworld
|
||||
ExecStart=/home/ubuntu/lena-inworld/venv/bin/python /home/ubuntu/lena-inworld/priyomnik.py 8095
|
||||
|
||||
# Уступать дорогу боевому рендеру (Р84).
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
|
||||
# Упал — поднимаемся, но не чаще раза в пять секунд.
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# Тайный ключ приёмника лежит ОТДЕЛЬНЫМ файлом с правами 600, как .kluch моста.
|
||||
# 🔴 В хранилище этого файла нет и быть не должно.
|
||||
# Environment=LENA_KLYUCH_FAYL=/home/ubuntu/lena-inworld/.kluch-priyomnik
|
||||
|
||||
# Память знаков заданий (Р124, сутки) — на постоянном диске, НЕ в /tmp:
|
||||
# /tmp вычищается целиком при каждой перезагрузке машины.
|
||||
# Environment=LENA_PAMYAT_ZADANIY=/home/ubuntu/lena-inworld/pamyat-zadaniy.sqlite3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,782 @@
|
||||
"""ПРИЁМНИК ЗАДАНИЙ: роботная половина шва «портал умеет позвать робота».
|
||||
|
||||
Портальная половина построена 06.08.2026 — это `app/app/Services/Obzvon/
|
||||
ObzvonClient.php`. Она умеет три вещи: попросить позвонить, спросить состояние
|
||||
разговора и велеть остановить. На той стороне до сегодня НЕ БЫЛО НИКОГО. Здесь
|
||||
появляется тот, кто эти три просьбы принимает.
|
||||
|
||||
Запуск: venv/bin/python priyomnik.py [порт]
|
||||
по умолчанию порт 8095, слушает 127.0.0.1
|
||||
Журнал: в stdout, как у моста.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
🔴🔴 ГЛАВНОЕ ПРАВИЛО ОТВЕТА — ОНО ЖЕ ЕДИНСТВЕННАЯ ЗАЩИТА ЖИВОГО ЧЕЛОВЕКА
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Портал читает ответ так (это его код, не наше пожелание):
|
||||
|
||||
200 и в теле {"ok": true} → «получен», задание ПРИНЯТО
|
||||
любой 4xx → «отказ», звонка НЕ БЫЛО, повторить можно
|
||||
любой 5xx → «не знаю», звонок МОГ состояться, не повторять
|
||||
200 без слова ok → «не знаю»
|
||||
молчание/обрыв → «не знаю»
|
||||
|
||||
Отсюда железное: **4xx разрешён ТОЛЬКО когда мы точно знаем, что набор не
|
||||
начинался.** Ответишь 4xx после того, как набор пошёл, — портал решит «звонка не
|
||||
было», повторит, и живому человеку позвонят дважды. Сомневаешься — 5xx.
|
||||
|
||||
Поэтому в этом файле каждый 4xx на ручке `pozvonit` стоит СТРОГО ДО вызова
|
||||
набора. Единственное исключение — когда сам набор поклялся, что не начинался
|
||||
(`Nabor.NE_NACHAT`), и это записано отдельным исходом, а не выведено из текста
|
||||
ошибки.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
🔴🔴 ПАМЯТЬ ЗНАКОВ — РЕШЕНИЕ ВЛАДЕЛЬЦА Р124, И ГДЕ ОНА ЖИВЁТ
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Р124: опознавательные знаки заданий помним СУТКИ. Знак пришёл второй раз в
|
||||
пределах суток — это повтор, звонить второй раз нельзя, надо вернуть ТОТ ЖЕ
|
||||
ответ, что и в первый раз. Через сутки тот же знак — законное новое задание
|
||||
(в знак входят клиент, кампания, номер и число попытки, а времени в нём нет:
|
||||
перезапустят ту же кампанию через месяц — знак будет тот же).
|
||||
|
||||
🔴 Память лежит на ДИСКЕ, в файле SQLite, и переживает перезапуск робота.
|
||||
Держать её в памяти работающей программы было бы защитой ровно до первого сбоя
|
||||
— то есть она исчезала бы именно тогда, когда нужнее всего. Задания сюда шлют
|
||||
как раз потому, что ответ мог потеряться по дороге; потеря ответа и падение
|
||||
робота — соседние беды, и лечить одну, ломаясь от другой, бессмысленно.
|
||||
|
||||
🔴 И не в `/tmp`. На этой машине `/tmp` вычищается ЦЕЛИКОМ при каждой
|
||||
перезагрузке правилом `D /tmp` — память знаков там умирала бы молча и ровно в
|
||||
тот день, когда машину перезагрузят. Файл лежит рядом с самим роботом, в
|
||||
`/home/ubuntu/lena-inworld/`.
|
||||
|
||||
Что именно в этой памяти лежит: знак задания (в нём телефон только хешем — так
|
||||
его делает портал), отпечаток тела задания (тоже необратимый, и снят под тайным
|
||||
ключом — см. ниже), выданный нами номер звонка, код и тело нашего ответа.
|
||||
**Ни телефона, ни имени, ни слов человека там нет и быть не должно.**
|
||||
|
||||
Строки старше суток удаляются при первой же записи. Это НЕ противоречит Р94
|
||||
(«на машине робота ничего не удалять»): Р94 про накопленный след разговоров
|
||||
живых людей, а здесь — наша собственная расписка со сроком жизни ровно в сутки,
|
||||
и срок этот назначен Р124. Не удалять её значило бы не исполнить Р124.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
🔴 ДВА ОДИНАКОВЫХ ЗНАКА ПОЧТИ ОДНОВРЕМЕННО — САМЫЙ ЗЛОЙ СЛУЧАЙ
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
«Повтор — верни тот же ответ» легко сказать, пока первого ответа уже нет.
|
||||
А если первое задание ЕЩЁ В РАБОТЕ и ответа пока не существует?
|
||||
|
||||
Врать нельзя ни в одну сторону:
|
||||
- ответить 4xx («звонка не было») — ложь: первый поток, может, уже набирает;
|
||||
- ответить 200 ok:true с выдуманным номером звонка — ложь другого рода.
|
||||
|
||||
Поэтому знак РЕЗЕРВИРУЕТСЯ ДО набора, одной неделимой записью в SQLite
|
||||
(`BEGIN IMMEDIATE` + первичный ключ). Кто резерв не получил — тот не набирает
|
||||
и отвечает **5xx «не знаю»**. Это единственный честный ответ: мы вправду не
|
||||
знаем, чем кончится чужой набор. Портал вслепую не повторит.
|
||||
|
||||
🪤 Отсюда же следствие, которое надо знать: если робот УПАЛ между резервом и
|
||||
ответом, запись останется «в работе», и все повторы этого знака будут получать
|
||||
5xx до конца суток. Это не поломка, а честное «не знаю»: мы вправду не знаем,
|
||||
успел ли упавший процесс набрать номер. Само рассосётся через сутки (Р124).
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
🔴🔴 ЗНАК ТОТ ЖЕ, А ТЕЛО ЗАДАНИЯ ДРУГОЕ — ЗАМЕРЕНО ВЛАДЕЛЬЦЕМ 07.08.2026
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Вся защита выше стоит на одном допущении: **повтор знака = повтор задания.**
|
||||
Владелец замерил годный по форме случай МИМО этого допущения: знак тот же, а
|
||||
телефон в теле ДРУГОЙ. Приёмник считал это повтором, второму человеку не звонил
|
||||
вовсе — молча, без единого признака, — и отдавал порталу номер звонка ПЕРВОГО
|
||||
задания. Портал привязал бы итог чужого разговора к другому человеку.
|
||||
|
||||
Проверить, правильно ли сложен знак, приёмник не может: он не знает, из чего
|
||||
портал его складывает. Зато он может заметить, что **тот же знак пришёл с другим
|
||||
телом**. Поэтому вместе со знаком запоминается ОТПЕЧАТОК ТЕЛА задания:
|
||||
|
||||
- отпечаток тот же → это вправду повтор, ведём себя как прежде;
|
||||
- отпечаток другой → это не повтор, а ошибка портала, и молчать о ней нельзя.
|
||||
|
||||
🔴 Отвечаем на расхождение **422**, то есть 4xx — «звонка не было». Это ПРАВДА:
|
||||
по второму телу мы не набирали ничего. Соблазн ответить 5xx («не знаю») был:
|
||||
повторять-то бессмысленно, пока портал не починят. Но 5xx здесь — ложь в самую
|
||||
опасную сторону: портал пометил бы человека как «может быть, звонили», и не
|
||||
позвонил бы ему НИКОГДА. Это ровно та беда, которую тут и лечат, только
|
||||
навсегда. Пустой повтор стоит одного запроса; молчаливый пропуск человека стоит
|
||||
человека. Врать нельзя, даже когда правда неудобна.
|
||||
|
||||
🔴 Почему именно 422, а не 409 «набор не начинался». Портал на 4xx пишет в
|
||||
журнал ТОЛЬКО код (`ObzvonClient::razobrat`, ветка `clientError`) — причину из
|
||||
тела он туда не кладёт. Значит код и есть всё сообщение, и два разных случая
|
||||
обязаны иметь разные коды, иначе на портальной стороне их не различить.
|
||||
|
||||
🔴 Список полей отпечатка считается ВЫЧИТАНИЕМ, а не перечислением
|
||||
(`POLYA_VNE_OTPECHATKA`). Перечисли мы поля — новое поле, о котором завтра
|
||||
забудут, оказалось бы МИМО защиты, а это и есть тот класс беды, что тут лечат.
|
||||
Вычитанием — новое поле само оказывается ПОД защитой. Из отпечатка вычтен ровно
|
||||
один `zadanie`: это сам знак, то есть ключ, а ключ не может быть частью своего
|
||||
же значения.
|
||||
|
||||
🔴 Отпечаток снимается не голым хешем, а хешем ПОД ТАЙНЫМ КЛЮЧОМ (hmac). Голый
|
||||
хеш от тела, в котором остался один телефон, перебирается за минуты — номеров
|
||||
всего одиннадцать знаков. Ключ лежит отдельным файлом с правами 600 и в память
|
||||
знаков не попадает: унесённый файл памяти не даёт по отпечатку ни телефона, ни
|
||||
имени, ни слов скрипта. Ключ для отпечатка выведен из тайного ключа двери
|
||||
отдельно, чтобы одна работа не расплачивалась за другую.
|
||||
|
||||
🪤 Чем за это плачено — говорю вслух, потому что честная граница дороже галочки:
|
||||
|
||||
- **тело сравнивается ДОСЛОВНО.** Телефон, записанный в первый раз как
|
||||
`+7 999…`, а в повторе как `7999…`, — это разное тело, хотя человек тот же
|
||||
(знак у портала считается от одних цифр, `ObzvonClient::klyuchZadaniya`).
|
||||
Приёмник телефонов не разбирает вовсе и разбирать не начнёт: это работа
|
||||
портала. Случись такое — приёмник заругается ВСЛУХ, названным кодом, и это
|
||||
будет верный признак, что портал шлёт номер не тем видом, каким его хешировал;
|
||||
- **лишнее поле — тоже расхождение.** Выкати портал новую версию, добавившую
|
||||
поле, между первой попыткой и повтором — честный повтор получит 422. Это
|
||||
громкий и чинимый отказ вместо тихого звонка не тому. Само рассосётся через
|
||||
сутки (Р124);
|
||||
- **смена тайного ключа обнуляет отпечатки.** Сутки после смены повторы
|
||||
заданий, отвеченных ДО неё, будут расхождением. Громко, ограничено сутками;
|
||||
- **строки, записанные ДО этой починки, отпечатка не имеют.** По ним сравнить
|
||||
не с чем, и они ведут себя как раньше — повтор есть повтор. Сутки после
|
||||
выката, дальше их не остаётся.
|
||||
|
||||
🔴 На расхождении память НЕ ТРОГАЕТСЯ вовсе: расписка первого задания цела,
|
||||
бронь не снимается, ответ не переписывается. Иначе лечение стоило бы второго
|
||||
звонка тому, кому уже позвонили.
|
||||
|
||||
🔴 И второму заданию мы тоже НЕ звоним. Соблазн «раз это другой человек — набери
|
||||
ему» неверен: мы не знаем, какое из двух тел настоящее, а знак после такого
|
||||
набора не охраняет уже ничего. Звонить живому человеку по сломанному уговору
|
||||
нельзя. Правильное место починки — портал: пока он даёт передавать знак и
|
||||
телефон врозь, эта дверь останется нужна.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
🔴 ИДЕМПОТЕНТНОСТЬ — ТОЛЬКО У `pozvonit`. И ЭТО НЕ ПРИДИРКА
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Портал шлёт заголовки `X-Obzvon-Zadanie` и `Idempotency-Key` на ВСЕХ ТРЁХ
|
||||
ручках. Но кладёт он туда РАЗНОЕ (замер по `ObzvonClient::obratitsya`):
|
||||
|
||||
pozvonit → метка = опознавательный знак задания
|
||||
sostoyanie → метка = НОМЕР ЗВОНКА (call_id)
|
||||
ostanovit → метка = НОМЕР ЗВОНКА (call_id)
|
||||
|
||||
Прими мы `Idempotency-Key` за ключ памяти на всех ручках — второй вопрос
|
||||
«что с разговором?» получил бы в ответ ЗАПОМНЕННОЕ состояние вместо нынешнего,
|
||||
и портал вечно видел бы разговор таким, каким он был в первую секунду.
|
||||
Поэтому память знаков применяется РОВНО к `pozvonit`. На двух других ручках
|
||||
метка служит лишь ЗАПАСНЫМ источником номера звонка, если его нет в теле;
|
||||
сверки «тело против заголовка» там нет — она есть только там, где расхождение
|
||||
стоит второго набора.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
🔴 ЧЕМ ПОДНЯТ И СКОЛЬКО СТОИТ (решение владельца Р84)
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Машина делит железо с рендером БОЕВОГО «Поиска клиентов». Поэтому здесь нет ни
|
||||
одной сторонней библиотеки: только то, что уже лежит в самом Python —
|
||||
`http.server`, `sqlite3`, `json`, `hmac`, `threading`. Ни Flask, ни FastAPI, ни
|
||||
uvicorn, ни отдельного цикла событий, ни второго процесса.
|
||||
|
||||
- один процесс, потоки создаются на запрос и гибнут вместе с ним;
|
||||
- одновременных запросов не больше `PREDEL_ODNOVREMENNYH` (по умолчанию 8);
|
||||
сверх того — 429, то есть честное «звонка не было»;
|
||||
- тело запроса читается не длиннее `PREDEL_TELA` (64 КБ), сверх — 413;
|
||||
- на запрос приходится не больше двух обращений к SQLite, файл лежит на
|
||||
локальном диске;
|
||||
- в покое приёмник спит на `accept()` и не ест ни процессора, ни диска.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
🔴 ЖУРНАЛ: НИ ТЕЛЕФОНА, НИ ИМЕНИ, НИ СЛОВ ЧЕЛОВЕКА
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
В тело задания портал кладёт номер телефона живого человека, а в `dopolnitelno`
|
||||
— имя собеседника и текст скрипта. Всё это персональные данные ровно того же
|
||||
рода, что и слова из трубки, которые 06.08.2026 убрали из журнала моста.
|
||||
|
||||
Поэтому здесь пишется ТОЛЬКО: имя ручки, опознавательный знак (телефон в нём
|
||||
необратим — портал кладёт туда хеш), номер звонка, код ответа и короткая
|
||||
причина. Тела запроса в журнале нет НИКОГДА, целиком, а не «по возможности»:
|
||||
проще не иметь такой строки вовсе, чем потом гадать, что в неё попало.
|
||||
|
||||
Тайный ключ в журнал не попадает тоже — ни целиком, ни куском.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
🔴 ЧЕГО ЭТОТ ФАЙЛ НЕ ДЕЛАЕТ — ГРАНИЦА НАЗВАНА ВСЛУХ
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
- **не набирает номер сам**. Набор — отдельная вещь, `Nabornik`, и сегодня
|
||||
настроен `NabornikNeNastroen`, который честно отказывает. Пока настоящего
|
||||
набора нет, приёмник на `pozvonit` отвечает 4xx «набор не настроен» —
|
||||
то есть говорит порталу правду: звонка не было. Ответить «принял», не имея
|
||||
чем звонить, было бы тихой ложью, и портал считал бы звонки, которых нет;
|
||||
- **не отчитывается о результате разговора**. Это вторая половина З-0.6, она
|
||||
живёт в `most.py` и ждёт своей задачи;
|
||||
- **не решает, звонить ли по этому номеру вообще** (часы, чёрные списки,
|
||||
деньги клиента) — это дело портала, и оно уже там.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
PAPKA = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Сутки — решение владельца Р124. Переменная нужна пробам, а не настройке:
|
||||
# менять срок в бою значило бы переигрывать решение владельца.
|
||||
SROK_ZNAKA_SEK = int(os.environ.get("LENA_SROK_ZNAKA_SEK", 24 * 60 * 60))
|
||||
|
||||
# Где лежит память знаков. НЕ в /tmp — см. шапку.
|
||||
PUT_PAMYATI = os.environ.get(
|
||||
"LENA_PAMYAT_ZADANIY", os.path.join(PAPKA, "pamyat-zadaniy.sqlite3"))
|
||||
|
||||
# Тайный ключ. Два входа, и оба нарочно: файл — как боевой (права 600, рядом с
|
||||
# `.kluch` моста), переменная — для проб. Пусто = ДВЕРЬ ЗАКРЫТА СОВСЕМ, а не
|
||||
# «пускаем всех»: ровно так устроены обе двери портала, и не случайно.
|
||||
PUT_KLYUCHA = os.environ.get("LENA_KLYUCH_FAYL", os.path.join(PAPKA, ".kluch-priyomnik"))
|
||||
|
||||
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8095
|
||||
ADRES = os.environ.get("LENA_PRIYOMNIK_ADRES", "127.0.0.1")
|
||||
|
||||
PREDEL_ODNOVREMENNYH = int(os.environ.get("LENA_PREDEL_ODNOVREMENNYH", 8))
|
||||
PREDEL_TELA = int(os.environ.get("LENA_PREDEL_TELA", 64 * 1024))
|
||||
|
||||
# 🔴 Что в отпечаток тела НЕ входит. Список именно ВЫЧИТАЕМЫХ полей, а не
|
||||
# участвующих: поле, которое заведут завтра и о котором тут забудут, обязано
|
||||
# оказаться ПОД защитой, а не мимо неё. Настройкой не делается намеренно —
|
||||
# ослабить защиту не должно быть так же просто, как поменять переменную среды.
|
||||
#
|
||||
# `zadanie` — это сам опознавательный знак, то есть ключ памяти. Ключ не может
|
||||
# быть частью своего же значения: он всегда равен себе и ничего не доказывает.
|
||||
POLYA_VNE_OTPECHATKA = frozenset({"zadanie"})
|
||||
|
||||
|
||||
def prochest_klyuch():
|
||||
"""Тайный ключ: сперва переменная, потом файл. Не нашли — пустая строка."""
|
||||
iz_sredy = os.environ.get("LENA_PRIYOMNIK_KLYUCH")
|
||||
if iz_sredy is not None:
|
||||
return iz_sredy.strip()
|
||||
try:
|
||||
with open(PUT_KLYUCHA, encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- журнал
|
||||
|
||||
def zapis(tekst):
|
||||
"""Строка журнала. 🔴 Сюда НЕ КЛАДЁТСЯ ни телефон, ни имя, ни ключ."""
|
||||
print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] приёмник: {tekst}", flush=True)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- сам набор
|
||||
|
||||
class Nabor:
|
||||
"""Что ответил набор. Ровно три исхода, и путать их НЕЛЬЗЯ.
|
||||
|
||||
🔴 Разница между первыми двумя — это разница между «человеку позвонят один
|
||||
раз» и «человеку позвонят дважды». Она обязана быть отдельным полем, а не
|
||||
выводиться из текста ошибки: текст ошибки меняют, не думая о последствиях.
|
||||
"""
|
||||
|
||||
NACHAT = "nachat" # набор ПОШЁЛ. Звонок мог состояться → 200 ok
|
||||
NE_NACHAT = "ne_nachat" # набор ТОЧНО не начинался → 4xx можно
|
||||
NE_ZNAYU = "ne_znayu" # неизвестно, пошёл ли → только 5xx
|
||||
|
||||
def __init__(self, ishod, call_id=None, prichina=""):
|
||||
self.ishod = ishod
|
||||
self.call_id = call_id
|
||||
self.prichina = prichina
|
||||
|
||||
|
||||
class NabornikNeNastroen:
|
||||
"""Набора нет. Честно отказывает — и это правда, а не заглушка-обманка.
|
||||
|
||||
🔴 Соблазн был сделать «принял и ничего не делаю»: тогда портальная сторона
|
||||
выглядела бы работающей. Это худшее из возможного — портал считал бы
|
||||
звонки, которых не было, и человек в отчёте увидел бы работу, которой нет.
|
||||
Отказ ЧЕСТЕН: звонка вправду не было.
|
||||
"""
|
||||
|
||||
imya = "не настроен"
|
||||
|
||||
def nabrat(self, zadanie):
|
||||
return Nabor(Nabor.NE_NACHAT, prichina="набор не настроен")
|
||||
|
||||
|
||||
def sozdat_nabornik():
|
||||
"""Кем набирать. Сегодня выбор один — и это сказано вслух, а не спрятано."""
|
||||
vybor = os.environ.get("LENA_NABORNIK", "net").strip()
|
||||
if vybor != "net":
|
||||
raise SystemExit(
|
||||
f"набор '{vybor}' неизвестен; сегодня умеем только 'net' "
|
||||
f"(настоящий набор — отдельная задача с окном на живые звонки)")
|
||||
return NabornikNeNastroen()
|
||||
|
||||
|
||||
# --------------------------------------------------------------- память знаков
|
||||
|
||||
class Pamyat:
|
||||
"""Память опознавательных знаков и выданных номеров звонков.
|
||||
|
||||
Живёт файлом на диске: перезапуск робота её не стирает (см. шапку).
|
||||
"""
|
||||
|
||||
def __init__(self, put=PUT_PAMYATI, srok=SROK_ZNAKA_SEK):
|
||||
self.put = put
|
||||
self.srok = srok
|
||||
self.zamok = threading.Lock()
|
||||
self.svyaz = sqlite3.connect(put, check_same_thread=False,
|
||||
isolation_level=None, timeout=10)
|
||||
self.svyaz.execute("PRAGMA journal_mode=WAL")
|
||||
self.svyaz.execute("""
|
||||
CREATE TABLE IF NOT EXISTS zadaniya (
|
||||
znak TEXT PRIMARY KEY,
|
||||
sostoyanie TEXT NOT NULL,
|
||||
kod INTEGER,
|
||||
otvet TEXT,
|
||||
call_id TEXT,
|
||||
sozdano REAL NOT NULL,
|
||||
otvecheno REAL,
|
||||
otpechatok TEXT
|
||||
)""")
|
||||
# Память, заведённая ДО починки 07.08.2026, столбца отпечатка не имеет.
|
||||
# Дописываем его на месте, а не сносим файл: снести файл значило бы
|
||||
# забыть все живые расписки и позвонить второй раз тем, кому уже звонили.
|
||||
stolbcy = {r[1] for r in self.svyaz.execute("PRAGMA table_info(zadaniya)")}
|
||||
if "otpechatok" not in stolbcy:
|
||||
self.svyaz.execute("ALTER TABLE zadaniya ADD COLUMN otpechatok TEXT")
|
||||
self.svyaz.execute("""
|
||||
CREATE TABLE IF NOT EXISTS zvonki (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
znak TEXT NOT NULL,
|
||||
sostoyanie TEXT NOT NULL,
|
||||
nachato REAL NOT NULL,
|
||||
obnovleno REAL NOT NULL
|
||||
)""")
|
||||
|
||||
def zakryt(self):
|
||||
with self.zamok:
|
||||
self.svyaz.close()
|
||||
|
||||
# -- знаки заданий
|
||||
|
||||
def zabronirovat(self, znak, otpechatok):
|
||||
"""Занять знак под набор. Неделимо.
|
||||
|
||||
Возвращает `(None, None)`, если знак НАШ — можно набирать.
|
||||
Возвращает `('drugoe_telo', None)`, если знак уже занят, но тело
|
||||
задания было ДРУГОЕ — это не повтор, а ошибка портала.
|
||||
Возвращает `('v_rabote', None)`, если знак занят чужим набором, ответа
|
||||
ещё нет. Возвращает `('otvecheno', строка)`, если ответ уже был.
|
||||
|
||||
🔴 Отпечаток кладётся в ту же неделимую запись, что и бронь, а НЕ вместе
|
||||
с ответом. Иначе у задания, которое ещё в работе, сравнивать было бы не
|
||||
с чем, и второе тело под тем же знаком получило бы «не знаю» вместо
|
||||
честного «звонка не было».
|
||||
"""
|
||||
seychas = time.time()
|
||||
with self.zamok:
|
||||
self.svyaz.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
# Сутки прошли — старая расписка больше не расписка (Р124).
|
||||
self.svyaz.execute("DELETE FROM zadaniya WHERE sozdano < ?",
|
||||
(seychas - self.srok,))
|
||||
stroka = self.svyaz.execute(
|
||||
"SELECT sostoyanie, kod, otvet, call_id, otpechatok "
|
||||
"FROM zadaniya WHERE znak = ?", (znak,)).fetchone()
|
||||
if stroka is not None:
|
||||
self.svyaz.execute("COMMIT")
|
||||
# 🪤 Пусто — это расписка, заведённая ДО починки 07.08.2026.
|
||||
# Сравнивать не с чем, поэтому ведём себя как прежде. Таких
|
||||
# строк остаётся ровно сутки после выката (Р124).
|
||||
if stroka[4] is not None and not hmac.compare_digest(
|
||||
str(stroka[4]), str(otpechatok)):
|
||||
return "drugoe_telo", None
|
||||
if stroka[0] == "v_rabote":
|
||||
return "v_rabote", None
|
||||
return "otvecheno", stroka
|
||||
self.svyaz.execute(
|
||||
"INSERT INTO zadaniya (znak, sostoyanie, sozdano, otpechatok) "
|
||||
"VALUES (?, 'v_rabote', ?, ?)", (znak, seychas, otpechatok))
|
||||
self.svyaz.execute("COMMIT")
|
||||
return None, None
|
||||
except BaseException:
|
||||
self.svyaz.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def zapomnit_otvet(self, znak, kod, otvet, call_id=None):
|
||||
"""Записать наш ответ на этот знак — им же ответим на все повторы."""
|
||||
with self.zamok:
|
||||
self.svyaz.execute(
|
||||
"UPDATE zadaniya SET sostoyanie='otvecheno', kod=?, otvet=?, "
|
||||
"call_id=?, otvecheno=? WHERE znak=?",
|
||||
(kod, json.dumps(otvet, ensure_ascii=False), call_id, time.time(), znak))
|
||||
|
||||
def snyat_bron(self, znak):
|
||||
"""Отпустить знак: набор поклялся, что не начинался.
|
||||
|
||||
🔴 Тонкое место. Держать бронь после честного «не набирал» нельзя —
|
||||
иначе первая же опечатка в задании закрывала бы этот номер на сутки,
|
||||
и исправленное задание молча не дозвонилось бы. А вот после «не знаю»
|
||||
бронь остаётся: там мы не вправе разрешать второй набор.
|
||||
"""
|
||||
with self.zamok:
|
||||
self.svyaz.execute("DELETE FROM zadaniya WHERE znak=? AND sostoyanie='v_rabote'",
|
||||
(znak,))
|
||||
|
||||
# -- сами звонки
|
||||
|
||||
def zapomnit_zvonok(self, call_id, znak):
|
||||
seychas = time.time()
|
||||
with self.zamok:
|
||||
self.svyaz.execute(
|
||||
"INSERT OR REPLACE INTO zvonki (call_id, znak, sostoyanie, nachato, obnovleno) "
|
||||
"VALUES (?, ?, 'nabiraem', ?, ?)", (call_id, znak, seychas, seychas))
|
||||
|
||||
def zvonok(self, call_id):
|
||||
with self.zamok:
|
||||
return self.svyaz.execute(
|
||||
"SELECT call_id, znak, sostoyanie, nachato FROM zvonki WHERE call_id = ?",
|
||||
(call_id,)).fetchone()
|
||||
|
||||
def poprosit_ostanovku(self, call_id):
|
||||
with self.zamok:
|
||||
kursor = self.svyaz.execute(
|
||||
"UPDATE zvonki SET sostoyanie='ostanovka_poproshena', obnovleno=? "
|
||||
"WHERE call_id=?", (time.time(), call_id))
|
||||
return kursor.rowcount > 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------- сам приёмник
|
||||
|
||||
class Priyomnik:
|
||||
"""Все три ручки. Про сеть НИЧЕГО не знает — её знает оболочка ниже.
|
||||
|
||||
Так сделано нарочно: пробу можно кормить настоящим кодом без сокетов,
|
||||
ровно как `proba-mosta-bez-zvonka.py` кормит мост без звука и без сети.
|
||||
"""
|
||||
|
||||
def __init__(self, pamyat, nabornik, klyuch):
|
||||
self.pamyat = pamyat
|
||||
self.nabornik = nabornik
|
||||
self.klyuch = (klyuch or "").strip()
|
||||
# 🔴 Ключ отпечатка выведен из ключа двери ОТДЕЛЬНЫМ шагом, а не взят
|
||||
# тем же самым: у двери и у отпечатка разные работы, и одна не должна
|
||||
# расплачиваться за другую. Сам ключ двери в память знаков не попадает.
|
||||
self._klyuch_otpechatka = hmac.new(
|
||||
self.klyuch.encode("utf-8"), b"otpechatok-tela-zadaniya",
|
||||
hashlib.sha256).digest()
|
||||
|
||||
# -- вспомогательное
|
||||
|
||||
def _otpechatok_tela(self, telo):
|
||||
"""Отпечаток тела задания. Необратим и снят под тайным ключом.
|
||||
|
||||
🔴 Поля берутся ВЫЧИТАНИЕМ (`POLYA_VNE_OTPECHATKA`): всё, кроме прямо
|
||||
вычтенного, в отпечаток входит — в том числе поле, которое заведут
|
||||
завтра, а сюда вписать забудут.
|
||||
|
||||
🪤 Порядок полей в JSON не значит ничего — его волен переставить кто
|
||||
угодно по дороге. Поэтому ключи сортируются, и отпечаток от порядка не
|
||||
зависит. Иначе перестановка выглядела бы как «другой человек».
|
||||
"""
|
||||
ochishchennoe = {k: v for k, v in telo.items() if k not in POLYA_VNE_OTPECHATKA}
|
||||
syroe = json.dumps(ochishchennoe, sort_keys=True, ensure_ascii=False,
|
||||
separators=(",", ":")).encode("utf-8")
|
||||
return hmac.new(self._klyuch_otpechatka, syroe, hashlib.sha256).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _metka(zagolovki):
|
||||
"""Опознавательная метка запроса — из заголовков, любого из двух.
|
||||
|
||||
Портал шлёт её дважды нарочно: посредник, срезающий нестандартный
|
||||
`X-Obzvon-Zadanie`, не должен молча обезоружить защиту. Значит и читать
|
||||
надо оба, а расхождение считать бедой: два разных значения — это либо
|
||||
посредник шалит, либо кто-то подбирает.
|
||||
"""
|
||||
nizhnie = {k.lower(): (v or "").strip() for k, v in zagolovki.items()}
|
||||
svoy = nizhnie.get("x-obzvon-zadanie", "")
|
||||
obshchiy = nizhnie.get("idempotency-key", "")
|
||||
if svoy and obshchiy and svoy != obshchiy:
|
||||
return None, "метки задания в двух заголовках не совпадают"
|
||||
return (svoy or obshchiy), ""
|
||||
|
||||
def _klyuch_veren(self, zagolovki):
|
||||
"""Пустой ключ в настройке = дверь закрыта совсем, а не «пускаем всех».
|
||||
|
||||
🔴 Сравнение — `hmac.compare_digest`, а не `==`. Обычное равенство
|
||||
обрывается на первом несовпавшем знаке, и по времени ответа ключ
|
||||
подбирается по букве. У портала это уже сделано правильно, здесь тоже.
|
||||
"""
|
||||
if self.klyuch == "":
|
||||
return False, "тайный ключ приёмника не задан — дверь закрыта"
|
||||
nizhnie = {k.lower(): (v or "") for k, v in zagolovki.items()}
|
||||
prishlo = nizhnie.get("x-obzvon-key", "").strip()
|
||||
if prishlo == "":
|
||||
return False, "задание без тайного ключа"
|
||||
if not hmac.compare_digest(prishlo.encode("utf-8"), self.klyuch.encode("utf-8")):
|
||||
return False, "чужой тайный ключ"
|
||||
return True, ""
|
||||
|
||||
# -- главная дверь
|
||||
|
||||
def obrabotat(self, ruchka, zagolovki, telo_bayty):
|
||||
"""Один запрос. Возвращает `(код, тело-ответа-словарём)`."""
|
||||
if ruchka not in ("pozvonit", "sostoyanie", "ostanovit"):
|
||||
zapis(f"ручка «{ruchka}» неизвестна → 404")
|
||||
return 404, {"ok": False, "reason": "ручка неизвестна"}
|
||||
|
||||
veren, pochemu = self._klyuch_veren(zagolovki)
|
||||
if not veren:
|
||||
# 🔴 4xx честен: до набора мы даже не дошли.
|
||||
zapis(f"{ruchka}: {pochemu} → 401")
|
||||
return 401, {"ok": False, "reason": pochemu}
|
||||
|
||||
if len(telo_bayty) > PREDEL_TELA:
|
||||
zapis(f"{ruchka}: тело больше предела ({len(telo_bayty)} байт) → 413")
|
||||
return 413, {"ok": False, "reason": "тело запроса слишком велико"}
|
||||
|
||||
try:
|
||||
telo = json.loads(telo_bayty.decode("utf-8")) if telo_bayty else {}
|
||||
except (ValueError, UnicodeDecodeError) as e:
|
||||
zapis(f"{ruchka}: тело не разобрать ({type(e).__name__}) → 400")
|
||||
return 400, {"ok": False, "reason": "тело запроса не разобрать"}
|
||||
if not isinstance(telo, dict):
|
||||
zapis(f"{ruchka}: тело не словарь → 400")
|
||||
return 400, {"ok": False, "reason": "тело запроса не словарь"}
|
||||
|
||||
metka, beda = self._metka(zagolovki)
|
||||
if beda:
|
||||
zapis(f"{ruchka}: {beda} → 400")
|
||||
return 400, {"ok": False, "reason": beda}
|
||||
|
||||
if ruchka == "pozvonit":
|
||||
return self._pozvonit(metka, telo)
|
||||
if ruchka == "sostoyanie":
|
||||
return self._sostoyanie(metka, telo)
|
||||
return self._ostanovit(metka, telo)
|
||||
|
||||
# -- ручка «позвони»
|
||||
|
||||
def _pozvonit(self, metka, telo):
|
||||
znak = str(telo.get("zadanie") or "").strip()
|
||||
telefon = str(telo.get("telefon") or "").strip()
|
||||
|
||||
# Знак в теле — главный: тело пишет сам портал, а заголовки по дороге
|
||||
# режут и переписывают посредники. Заголовок при этом обязан сойтись.
|
||||
if znak == "":
|
||||
znak = metka
|
||||
elif metka and metka != znak:
|
||||
zapis("pozvonit: знак в теле и в заголовке разные → 400")
|
||||
return 400, {"ok": False, "reason": "знак задания в теле и в заголовке разный"}
|
||||
|
||||
if znak == "":
|
||||
zapis("pozvonit: задание без опознавательного знака → 400")
|
||||
return 400, {"ok": False, "reason": "задание без опознавательного знака"}
|
||||
if telefon == "":
|
||||
zapis(f"pozvonit: задание {znak} без номера → 400")
|
||||
return 400, {"ok": False, "reason": "задание без номера"}
|
||||
|
||||
chuzhoe, stroka = self.pamyat.zabronirovat(znak, self._otpechatok_tela(telo))
|
||||
|
||||
if chuzhoe == "drugoe_telo":
|
||||
# 🔴 Тот же знак с ДРУГИМ телом. Это не повтор — это ошибка портала,
|
||||
# и второму человеку сейчас звонить нельзя: мы не знаем, какое из
|
||||
# двух тел настоящее. Память НЕ трогаем вовсе: расписка первого
|
||||
# задания цела, бронь на месте, ответ не переписан.
|
||||
#
|
||||
# 🔴 422 — это 4xx, то есть «звонка не было», и это ПРАВДА: по этому
|
||||
# телу мы не набирали ничего. 5xx сказало бы «может быть, звонили», и
|
||||
# человек не дождался бы звонка никогда. Почему код отдельный, а не
|
||||
# общий с 409, — см. шапку.
|
||||
zapis(f"pozvonit: 🔴 знак {znak} уже занят ДРУГИМ телом задания — "
|
||||
f"это ошибка портала, набора не было → 422")
|
||||
return 422, {"ok": False,
|
||||
"reason": "этот знак задания уже занят другим телом задания"}
|
||||
|
||||
if chuzhoe == "v_rabote":
|
||||
# 🔴 Два одинаковых знака почти одновременно. Ответа ещё нет, и
|
||||
# выдумывать его нельзя ни в какую сторону — см. шапку.
|
||||
zapis(f"pozvonit: задание {znak} уже в работе, ответа ещё нет → 503")
|
||||
return 503, {"ok": False, "reason": "задание уже в работе, ответа ещё нет"}
|
||||
|
||||
if chuzhoe == "otvecheno":
|
||||
kod, syroy_otvet, call_id = stroka[1], stroka[2], stroka[3]
|
||||
otvet = json.loads(syroy_otvet) if syroy_otvet else {"ok": False}
|
||||
otvet = dict(otvet, povtor=True)
|
||||
zapis(f"pozvonit: задание {znak} — ПОВТОР в пределах суток, "
|
||||
f"второго набора нет, отвечаем тем же ({kod}"
|
||||
f"{', звонок ' + call_id if call_id else ''})")
|
||||
return kod, otvet
|
||||
|
||||
# Знак наш. С этой секунды и до записи ответа мы одни за него отвечаем.
|
||||
try:
|
||||
nabor = self.nabornik.nabrat(dict(telo))
|
||||
except Exception as e: # noqa: BLE001
|
||||
# 🔴 Набор упал непонятно где. Успел он снять трубку или нет — мы
|
||||
# НЕ ЗНАЕМ, поэтому 5xx, и бронь НЕ снимаем.
|
||||
zapis(f"pozvonit: задание {znak} — набор упал "
|
||||
f"({type(e).__name__}) → 503, повторять вслепую нельзя")
|
||||
otvet = {"ok": False, "reason": "набор упал, состоялся ли звонок — неизвестно"}
|
||||
self.pamyat.zapomnit_otvet(znak, 503, otvet)
|
||||
return 503, otvet
|
||||
|
||||
if nabor.ishod == Nabor.NE_NACHAT:
|
||||
# Набор поклялся, что не начинался. Только тут 4xx после набора.
|
||||
zapis(f"pozvonit: задание {znak} — набор не начинался: {nabor.prichina} → 409")
|
||||
self.pamyat.snyat_bron(znak)
|
||||
return 409, {"ok": False, "reason": nabor.prichina}
|
||||
|
||||
if nabor.ishod == Nabor.NE_ZNAYU:
|
||||
zapis(f"pozvonit: задание {znak} — набор ответил «не знаю»: "
|
||||
f"{nabor.prichina} → 503")
|
||||
otvet = {"ok": False, "reason": nabor.prichina}
|
||||
self.pamyat.zapomnit_otvet(znak, 503, otvet)
|
||||
return 503, otvet
|
||||
|
||||
call_id = nabor.call_id or ("lena-" + uuid.uuid4().hex)
|
||||
otvet = {"ok": True, "call_id": call_id, "povtor": False}
|
||||
self.pamyat.zapomnit_zvonok(call_id, znak)
|
||||
self.pamyat.zapomnit_otvet(znak, 200, otvet, call_id)
|
||||
zapis(f"pozvonit: задание {znak} принято, звонок {call_id}")
|
||||
return 200, otvet
|
||||
|
||||
# -- ручка «что с разговором»
|
||||
|
||||
def _sostoyanie(self, metka, telo):
|
||||
call_id = str(telo.get("call_id") or "").strip() or metka
|
||||
if call_id == "":
|
||||
zapis("sostoyanie: не назван номер звонка → 400")
|
||||
return 400, {"ok": False, "reason": "не назван номер звонка"}
|
||||
|
||||
stroka = self.pamyat.zvonok(call_id)
|
||||
if stroka is None:
|
||||
zapis(f"sostoyanie: звонок {call_id} неизвестен → 404")
|
||||
return 404, {"ok": False, "reason": "звонок неизвестен"}
|
||||
|
||||
zapis(f"sostoyanie: звонок {call_id} — {stroka[2]}")
|
||||
return 200, {"ok": True, "call_id": call_id, "sostoyanie": stroka[2],
|
||||
"idyot_sekund": round(time.time() - stroka[3], 1)}
|
||||
|
||||
# -- ручка «останови»
|
||||
|
||||
def _ostanovit(self, metka, telo):
|
||||
call_id = str(telo.get("call_id") or "").strip() or metka
|
||||
if call_id == "":
|
||||
zapis("ostanovit: не назван номер звонка → 400")
|
||||
return 400, {"ok": False, "reason": "не назван номер звонка"}
|
||||
|
||||
if not self.pamyat.poprosit_ostanovku(call_id):
|
||||
zapis(f"ostanovit: звонок {call_id} неизвестен → 404")
|
||||
return 404, {"ok": False, "reason": "звонок неизвестен"}
|
||||
|
||||
# 🪤 «Принято» — про просьбу, а не про тишину в трубке. Портал это знает
|
||||
# и написал у себя ровно так же: убедиться можно только `sostoyanie`.
|
||||
zapis(f"ostanovit: звонок {call_id} — просьба принята")
|
||||
return 200, {"ok": True, "call_id": call_id, "prinyato": True}
|
||||
|
||||
|
||||
# --------------------------------------------------------- оболочка на сокете
|
||||
|
||||
class Ruchki(BaseHTTPRequestHandler):
|
||||
"""Тонкая оболочка: разобрать HTTP, позвать `Priyomnik`, отдать ответ."""
|
||||
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "lena-priyomnik"
|
||||
sys_version = "" # версию Python наружу не объявляем
|
||||
|
||||
def log_message(self, *_):
|
||||
"""Свой журнал у нас есть. Штатный писал бы адреса и пути в stderr."""
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
self._otvetit(405, {"ok": False, "reason": "только POST"})
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
ruchka = self.path.split("?")[0].strip("/")
|
||||
if not self.server.mesta.acquire(blocking=False):
|
||||
# Мест нет. Мы точно ничего не набирали → 4xx честен.
|
||||
self._otvetit(429, {"ok": False, "reason": "приёмник занят"})
|
||||
return
|
||||
try:
|
||||
try:
|
||||
dlina = int(self.headers.get("Content-Length") or 0)
|
||||
except ValueError:
|
||||
dlina = -1
|
||||
if dlina < 0:
|
||||
# Кривая длина тела. Набора не было точно → 4xx честен, и он
|
||||
# лучше пятисотого: портал вправе повторить этот звонок.
|
||||
self._otvetit(400, {"ok": False, "reason": "кривая длина тела"})
|
||||
return
|
||||
if dlina > PREDEL_TELA:
|
||||
self._otvetit(413, {"ok": False, "reason": "тело запроса слишком велико"})
|
||||
return
|
||||
telo = self.rfile.read(dlina) if dlina > 0 else b""
|
||||
kod, otvet = self.server.priyomnik.obrabotat(ruchka, dict(self.headers), telo)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# 🔴 Непонятная беда. Мы не знаем, дошло ли дело до набора → 5xx.
|
||||
zapis(f"{ruchka}: приёмник споткнулся ({type(e).__name__}) → 500")
|
||||
kod, otvet = 500, {"ok": False, "reason": "приёмник споткнулся"}
|
||||
finally:
|
||||
self.server.mesta.release()
|
||||
self._otvetit(kod, otvet)
|
||||
|
||||
def _otvetit(self, kod, telo):
|
||||
syroe = json.dumps(telo, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(kod)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(syroe)))
|
||||
self.end_headers()
|
||||
self.wfile.write(syroe)
|
||||
|
||||
|
||||
def podnyat(port=PORT, adres=ADRES, pamyat=None, nabornik=None, klyuch=None):
|
||||
"""Поднять приёмник на сокете. Отдаёт сервер — звать `serve_forever`."""
|
||||
server = ThreadingHTTPServer((adres, port), Ruchki)
|
||||
server.daemon_threads = True
|
||||
server.pamyat = pamyat or Pamyat()
|
||||
server.priyomnik = Priyomnik(
|
||||
server.pamyat,
|
||||
nabornik or sozdat_nabornik(),
|
||||
prochest_klyuch() if klyuch is None else klyuch)
|
||||
server.mesta = threading.Semaphore(PREDEL_ODNOVREMENNYH)
|
||||
return server
|
||||
|
||||
|
||||
def main():
|
||||
nabornik = sozdat_nabornik()
|
||||
klyuch = prochest_klyuch()
|
||||
server = podnyat(nabornik=nabornik, klyuch=klyuch)
|
||||
zapis(f"слушаю {ADRES}:{PORT}; память знаков {PUT_PAMYATI}; "
|
||||
f"срок знака {SROK_ZNAKA_SEK} с; набор «{nabornik.imya}»; "
|
||||
f"мест одновременных {PREDEL_ODNOVREMENNYH}; "
|
||||
f"ключ {'задан' if klyuch else '🔴 НЕ ЗАДАН — дверь закрыта'}")
|
||||
if not klyuch:
|
||||
zapis("🔴 без тайного ключа приёмник отвечает 401 на всё. "
|
||||
"Ключ кладётся файлом с правами 600 рядом с роботом.")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
zapis("остановлен с клавиатуры")
|
||||
finally:
|
||||
server.server_close()
|
||||
server.pamyat.zakryt()
|
||||
|
||||
|
||||
# Сервер поднимаем только при прямом запуске: пробы ввозят этот файл, чтобы
|
||||
# кормить настоящий код напрямую, и второй сокет им не нужен.
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,728 @@
|
||||
"""Проба приёмника заданий БЕЗ ЗВОНКА: подставной набор кормит настоящий код.
|
||||
|
||||
🔴 Зачем это вообще. Приёмник стоит на пути между порталом и живым человеком:
|
||||
ошибка здесь стоит второго звонка тому, кому уже звонили, а набранный номер
|
||||
отыграть нельзя. Проверять это живыми звонками нельзя дважды: звонок — тяжёлая
|
||||
нагрузка на машину, которая делит железо с рендером боевого сайта (решение
|
||||
владельца Р84), и пробы с настоящими звонками отложены до отдельного окна
|
||||
(Р96, Р101). Значит доказывать надо без звонка.
|
||||
|
||||
Как. Приёмник ничего не знает про то, чем набирают номер: набор он получает
|
||||
снаружи и зовёт у него один метод. Здесь подставляется набор, который номера не
|
||||
набирает, а **считает, сколько раз его позвали**. Это и есть главный прибор:
|
||||
«человеку позвонили дважды» здесь выглядит как «набор позвали два раза».
|
||||
|
||||
Второй прибор — сам журнал: приёмник пишет его в stdout, и проба его ловит.
|
||||
|
||||
Запуск (где угодно, ни сети, ни телефонии не нужно):
|
||||
python proba-priyomnika.py
|
||||
|
||||
Возвращает 0, если всё цело, и 1, если хоть одна проверка покраснела.
|
||||
|
||||
🔴 ЧЕГО ЭТА ПРОБА НЕ ПРОВЕРЯЕТ — говорю вслух, потому что честная граница
|
||||
дороже красивой галочки:
|
||||
- что робот вправду позвонит по заданию. Настоящего набора сегодня нет
|
||||
вовсе (`NabornikNeNastroen`), и это отдельная задача с окном на живые
|
||||
звонки. Здесь доказывается только то, КАК приёмник отвечает и сколько раз
|
||||
он трогает набор;
|
||||
- что портал и приёмник сойдутся на живой сети. Форма заголовков и разбор
|
||||
ответа взяты из кода портала (`ObzvonClient.php`), но живого обмена между
|
||||
двумя машинами тут нет;
|
||||
- что сравнение ключей вправду постоянно по времени. Это проверяется
|
||||
**чтением кода** (стоит ли `hmac.compare_digest`), а не замером: замер
|
||||
времени на такой мелочи шумит сильнее, чем сама разница;
|
||||
- что служба поднята на машине робота и работает свежим текстом. Это дело
|
||||
сторожа со сверкой слепков, и его здесь нет.
|
||||
"""
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import tracemalloc
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
# 🪤 Иначе рядом с роботом заводится папка __pycache__ — лишний файл в папке,
|
||||
# за составом которой следит сторож суточного обхода. Проба не должна оставлять
|
||||
# после себя ничего.
|
||||
sys.dont_write_bytecode = True
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import priyomnik # noqa: E402 — путь надо подложить раньше ввоза
|
||||
from priyomnik import Nabor, Pamyat, Priyomnik # noqa: E402
|
||||
|
||||
# Подставные данные. 🔴 Настоящих телефонов и имён живых людей здесь нет и быть
|
||||
# не должно. Приёмник телефон не разбирает вовсе — ему это просто строка, —
|
||||
# поэтому вместо правдоподобного номера взята приметная выдумка: так её и в
|
||||
# журнале искать надёжнее, и в хранилище не лежит ничего похожего на номер
|
||||
# живого человека.
|
||||
TAYNYY_TELEFON = "ТЕЛЕФОН-ЯБЛОКО-ГРУША-ВЕРБЛЮД"
|
||||
# Второй человек — тот, кому в замеренной владельцем дыре НЕ позвонили вовсе.
|
||||
# Приметен другим словом на конце: так его видно и в журнале, и в файле памяти.
|
||||
DRUGOY_TELEFON = "ТЕЛЕФОН-СЛИВА-АНАНАС-ЖИРАФ"
|
||||
TAYNOE_IMYA = "Кондратий Апполинариевич"
|
||||
TAYNYY_SKRIPT = "СКРИПТ-АРБУЗ-ДЫНЯ такого никто не говорил"
|
||||
# 🪤 Ключ пробы — ЛАТИНИЦЕЙ, и это не вкусовщина: заголовки HTTP ездят в
|
||||
# latin-1, и ключ с кириллицей физически не отправить. Замерено здесь же —
|
||||
# первая же попытка послать по сети кириллический ключ упала с
|
||||
# UnicodeEncodeError ещё в http.client, до всякого приёмника.
|
||||
KLYUCH = "proba-priyomnika-slovo-iz-tridtsati-znakov"
|
||||
CHUZHOY_KLYUCH = "proba-priyomnika-slovo-iz-tridtsati-znakoW" # отличие в конце
|
||||
|
||||
PROVALOV = 0
|
||||
PROVERENO = 0
|
||||
|
||||
|
||||
def zelenyy(chto):
|
||||
global PROVERENO
|
||||
PROVERENO += 1
|
||||
print(f" ✅ {chto}")
|
||||
|
||||
|
||||
def krasnyy(chto):
|
||||
global PROVERENO, PROVALOV
|
||||
PROVERENO += 1
|
||||
PROVALOV += 1
|
||||
print(f" 🔴 {chto}")
|
||||
|
||||
|
||||
def ravno(chto, bylo, nado):
|
||||
if bylo == nado:
|
||||
zelenyy(f"{chto}: {bylo}")
|
||||
else:
|
||||
krasnyy(f"{chto}: получено {bylo!r}, а надо {nado!r}")
|
||||
|
||||
|
||||
def v_predelah(chto, kod, ot, do):
|
||||
if ot <= kod < do:
|
||||
zelenyy(f"{chto}: HTTP {kod}")
|
||||
else:
|
||||
krasnyy(f"{chto}: HTTP {kod}, а надо {ot}..{do - 1}")
|
||||
|
||||
|
||||
# ------------------------------------------------------------ подставной набор
|
||||
|
||||
class PoddelnyyNabornik:
|
||||
"""Номера не набирает. Считает, сколько раз его позвали, и что принесли."""
|
||||
|
||||
imya = "подставной"
|
||||
|
||||
def __init__(self, ishod=Nabor.NACHAT, zaderzhka=0.0, ronyat=False):
|
||||
self.ishod = ishod
|
||||
self.zaderzhka = zaderzhka
|
||||
self.ronyat = ronyat
|
||||
self.naborov = 0
|
||||
self.zadaniya = []
|
||||
self.zamok = threading.Lock()
|
||||
|
||||
def nabrat(self, zadanie):
|
||||
with self.zamok:
|
||||
self.naborov += 1
|
||||
self.zadaniya.append(zadanie)
|
||||
if self.zaderzhka:
|
||||
time.sleep(self.zaderzhka)
|
||||
if self.ronyat:
|
||||
raise RuntimeError("подставная беда внутри набора")
|
||||
if self.ishod == Nabor.NACHAT:
|
||||
return Nabor(Nabor.NACHAT, call_id=f"proba-{self.naborov}")
|
||||
return Nabor(self.ishod, prichina="подставная причина")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- оснастка
|
||||
|
||||
VREMENNAYA = tempfile.mkdtemp(prefix="lena-proba-priyomnika-")
|
||||
SCHYOT_SQL = [0]
|
||||
# 🪤 Windows не отдаёт файл, пока его держит открытым SQLite. Держим список всех
|
||||
# памятей, чтобы закрыть их ДО уборки, иначе проба гадит временными папками.
|
||||
PAMYATI = []
|
||||
|
||||
|
||||
def sobrat(imya_fayla="pamyat.sqlite3", nabornik=None, klyuch=KLYUCH, srok=None):
|
||||
"""Собрать приёмник со своей памятью на диске. Отдаёт (приёмник, память)."""
|
||||
pamyat = Pamyat(put=os.path.join(VREMENNAYA, imya_fayla),
|
||||
srok=priyomnik.SROK_ZNAKA_SEK if srok is None else srok)
|
||||
PAMYATI.append(pamyat)
|
||||
pamyat.svyaz.set_trace_callback(lambda _: SCHYOT_SQL.__setitem__(0, SCHYOT_SQL[0] + 1))
|
||||
return Priyomnik(pamyat, nabornik or PoddelnyyNabornik(), klyuch), pamyat
|
||||
|
||||
|
||||
def zagolovki(znak, klyuch=KLYUCH, oba=True, vtoraya_metka=None):
|
||||
"""Заголовки ровно такие, какие шлёт портал (см. ObzvonClient::sprosit)."""
|
||||
g = {"Content-Type": "application/json"}
|
||||
if klyuch is not None:
|
||||
g["X-Obzvon-Key"] = klyuch
|
||||
if znak is not None:
|
||||
g["X-Obzvon-Zadanie"] = znak
|
||||
if oba:
|
||||
g["Idempotency-Key"] = vtoraya_metka if vtoraya_metka is not None else znak
|
||||
return g
|
||||
|
||||
|
||||
def poprosit(p, ruchka, telo, znak=None, klyuch=KLYUCH, oba=True, vtoraya_metka=None):
|
||||
"""Один запрос к приёмнику. Журнал перехватывается и отдаётся третьим."""
|
||||
byl = sys.stdout
|
||||
lovushka = io.StringIO()
|
||||
sys.stdout = lovushka
|
||||
try:
|
||||
kod, otvet = p.obrabotat(
|
||||
ruchka, zagolovki(znak, klyuch, oba, vtoraya_metka),
|
||||
json.dumps(telo, ensure_ascii=False).encode("utf-8"))
|
||||
finally:
|
||||
sys.stdout = byl
|
||||
return kod, otvet, lovushka.getvalue()
|
||||
|
||||
|
||||
def zadanie(znak, telefon=TAYNYY_TELEFON):
|
||||
return {"zadanie": znak, "telefon": telefon,
|
||||
"imya": TAYNOE_IMYA, "skript": TAYNYY_SKRIPT}
|
||||
|
||||
|
||||
ISHODNIK = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"priyomnik.py"), encoding="utf-8").read()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- прогоны --
|
||||
|
||||
print("Проба приёмника без звонка. Приёмник:", priyomnik.__file__)
|
||||
print()
|
||||
|
||||
# ============================================================================
|
||||
print("1. Тайный ключ — дверь")
|
||||
# ============================================================================
|
||||
p, _ = sobrat("k1.sqlite3", klyuch="")
|
||||
for ruchka, telo in (("pozvonit", zadanie("z-kl-1")),
|
||||
("sostoyanie", {"call_id": "нет-такого"}),
|
||||
("ostanovit", {"call_id": "нет-такого"})):
|
||||
kod, _o, _zh = poprosit(p, ruchka, telo, znak="z-kl-1")
|
||||
ravno(f"пустой ключ в настройке — «{ruchka}» закрыта, а не открыта всем", kod, 401)
|
||||
|
||||
p, _ = sobrat("k2.sqlite3")
|
||||
kod, _o, _zh = poprosit(p, "pozvonit", zadanie("z-kl-2"), znak="z-kl-2",
|
||||
klyuch=CHUZHOY_KLYUCH)
|
||||
ravno("чужой ключ — отказ", kod, 401)
|
||||
kod, _o, _zh = poprosit(p, "pozvonit", zadanie("z-kl-3"), znak="z-kl-3", klyuch=None)
|
||||
ravno("ключа нет вовсе — отказ", kod, 401)
|
||||
kod, _o, _zh = poprosit(p, "pozvonit", zadanie("z-kl-4"), znak="z-kl-4", klyuch="")
|
||||
ravno("ключ пустой строкой — отказ", kod, 401)
|
||||
kod, otvet, _zh = poprosit(p, "pozvonit", zadanie("z-kl-5"), znak="z-kl-5")
|
||||
ravno("верный ключ — пускает", kod, 200)
|
||||
|
||||
# 🔴 ЗДЕСЬ Я УЖЕ ОШИБСЯ ОДИН РАЗ, 07.08.2026. Сперва тут стояло
|
||||
# `if "compare_digest" in ISHODNIK` — поиск по ТЕКСТУ файла. Я заменил в
|
||||
# приёмнике настоящий `hmac.compare_digest` на голое `==` и проба осталась
|
||||
# ЗЕЛЁНОЙ: слова «compare_digest» она нашла в КОММЕНТАРИИ над этой самой
|
||||
# строкой. Сторож читал пояснение, а не работу. Теперь он смотрит в НАЗВАНИЯ,
|
||||
# которые вправду зовёт скомпилированный код, — комментарий туда не попадает.
|
||||
IMENA_V_KODE = Priyomnik._klyuch_veren.__code__.co_names
|
||||
if "compare_digest" in IMENA_V_KODE:
|
||||
zelenyy("проверка ключа вправду ЗОВЁТ hmac.compare_digest (смотрим в код, не в текст)")
|
||||
else:
|
||||
krasnyy("🔴 проверка ключа не зовёт compare_digest — ключ подбирается по времени ответа. "
|
||||
f"Зовёт: {', '.join(IMENA_V_KODE)}")
|
||||
print()
|
||||
|
||||
# ============================================================================
|
||||
print("2. Двойной звонок: тот же знак в пределах суток — Р124")
|
||||
# ============================================================================
|
||||
nabor = PoddelnyyNabornik()
|
||||
p, pam = sobrat("d1.sqlite3", nabornik=nabor)
|
||||
kod1, otvet1, _zh = poprosit(p, "pozvonit", zadanie("z-dva"), znak="z-dva")
|
||||
kod2, otvet2, zhurnal2 = poprosit(p, "pozvonit", zadanie("z-dva"), znak="z-dva")
|
||||
|
||||
ravno("первое задание принято", kod1, 200)
|
||||
ravno("🔴 НАБОР ПОЗВАН РОВНО ОДИН РАЗ на два одинаковых знака", nabor.naborov, 1)
|
||||
ravno("повтор отвечает тем же кодом", kod2, kod1)
|
||||
ravno("повтор отдаёт ТОТ ЖЕ номер звонка", otvet2.get("call_id"), otvet1.get("call_id"))
|
||||
ravno("повтор помечен как повтор", otvet2.get("povtor"), True)
|
||||
ravno("первый ответ повтором НЕ помечен", otvet1.get("povtor"), False)
|
||||
if "ПОВТОР" in zhurnal2:
|
||||
zelenyy("повтор виден в журнале — наладка не ослепла")
|
||||
else:
|
||||
krasnyy(f"повтор в журнале не назван. Журнал: {zhurnal2.strip()}")
|
||||
|
||||
kod3, otvet3, _zh = poprosit(p, "pozvonit", zadanie("z-tri"), znak="z-tri")
|
||||
ravno("другой знак — законный второй набор", nabor.naborov, 2)
|
||||
if otvet3.get("call_id") != otvet1.get("call_id"):
|
||||
zelenyy("у другого задания свой номер звонка")
|
||||
else:
|
||||
krasnyy("🔴 два разных задания получили ОДИН номер звонка")
|
||||
print()
|
||||
|
||||
# ============================================================================
|
||||
print("3. Сутки прошли — тот же знак снова законен (граница Р124)")
|
||||
# ============================================================================
|
||||
nabor = PoddelnyyNabornik()
|
||||
p, pam = sobrat("s1.sqlite3", nabornik=nabor)
|
||||
poprosit(p, "pozvonit", zadanie("z-sutki"), znak="z-sutki")
|
||||
|
||||
# Состариваем расписку на 23 часа — ещё в пределах суток.
|
||||
pam.svyaz.execute("UPDATE zadaniya SET sozdano = sozdano - ?", (23 * 3600,))
|
||||
kod, otvet, _zh = poprosit(p, "pozvonit", zadanie("z-sutki"), znak="z-sutki")
|
||||
ravno("через 23 часа тот же знак — всё ещё повтор, набора нет", nabor.naborov, 1)
|
||||
ravno("и ответ тот же", otvet.get("povtor"), True)
|
||||
|
||||
# А теперь на 25 часов от начала — сутки вышли.
|
||||
pam.svyaz.execute("UPDATE zadaniya SET sozdano = sozdano - ?", (2 * 3600,))
|
||||
kod, otvet, _zh = poprosit(p, "pozvonit", zadanie("z-sutki"), znak="z-sutki")
|
||||
ravno("через 25 часов тот же знак — ЗАКОННОЕ новое задание", nabor.naborov, 2)
|
||||
ravno("новое задание принято", kod, 200)
|
||||
ravno("и повтором не помечено", otvet.get("povtor"), False)
|
||||
print()
|
||||
|
||||
# ============================================================================
|
||||
print("4. 🔴 Память знаков переживает ПЕРЕЗАПУСК робота")
|
||||
# ============================================================================
|
||||
put = os.path.join(VREMENNAYA, "perezapusk.sqlite3")
|
||||
nabor_a = PoddelnyyNabornik()
|
||||
pam_a = Pamyat(put=put)
|
||||
p_a = Priyomnik(pam_a, nabor_a, KLYUCH)
|
||||
kod_a, otvet_a, _zh = poprosit(p_a, "pozvonit", zadanie("z-perezapusk"), znak="z-perezapusk")
|
||||
pam_a.zakryt() # робота остановили
|
||||
|
||||
nabor_b = PoddelnyyNabornik() # робот поднялся заново
|
||||
pam_b = Pamyat(put=put)
|
||||
p_b = Priyomnik(pam_b, nabor_b, KLYUCH)
|
||||
kod_b, otvet_b, _zh = poprosit(p_b, "pozvonit", zadanie("z-perezapusk"), znak="z-perezapusk")
|
||||
|
||||
ravno("🔴 после перезапуска повтор УЗНАН — второго набора нет", nabor_b.naborov, 0)
|
||||
ravno("после перезапуска отдан тот же номер звонка",
|
||||
otvet_b.get("call_id"), otvet_a.get("call_id"))
|
||||
ravno("после перезапуска помечено повтором", otvet_b.get("povtor"), True)
|
||||
if "/tmp" not in priyomnik.PUT_PAMYATI.replace("\\", "/"):
|
||||
zelenyy("память знаков лежит НЕ в /tmp — перезагрузка машины её не сотрёт")
|
||||
else:
|
||||
krasnyy("🔴 память знаков лежит в /tmp — перезагрузка машины сотрёт её молча")
|
||||
pam_b.zakryt()
|
||||
print()
|
||||
|
||||
# ============================================================================
|
||||
print("5. 🔴 Два одинаковых знака ОДНОВРЕМЕННО — первый ещё в работе")
|
||||
# ============================================================================
|
||||
nabor = PoddelnyyNabornik(zaderzhka=0.4)
|
||||
p, _ = sobrat("odnovremenno.sqlite3", nabornik=nabor)
|
||||
itogi = []
|
||||
|
||||
|
||||
def gonets():
|
||||
itogi.append(poprosit(p, "pozvonit", zadanie("z-gonka"), znak="z-gonka")[:2])
|
||||
|
||||
|
||||
potoki = [threading.Thread(target=gonets) for _ in range(2)]
|
||||
for t in potoki:
|
||||
t.start()
|
||||
time.sleep(0.05) # второй заведомо попадает в «ещё в работе»
|
||||
for t in potoki:
|
||||
t.join()
|
||||
|
||||
kody = sorted(k for k, _ in itogi)
|
||||
ravno("🔴 при гонке набор позван РОВНО ОДИН раз", nabor.naborov, 1)
|
||||
ravno("один из двух получил «принято»", kody[0], 200)
|
||||
v_predelah("🔴 второй получил «НЕ ЗНАЮ», а не «звонка не было»", kody[1], 500, 600)
|
||||
if 400 <= kody[1] < 500:
|
||||
krasnyy("🔴🔴 второму ответили 4xx — портал решит «звонка не было» и позвонит ВТОРОЙ РАЗ")
|
||||
else:
|
||||
zelenyy("4xx при гонке не выдан — двойной звонок этим путём невозможен")
|
||||
print()
|
||||
|
||||
# ============================================================================
|
||||
print("6. Правило ответа: 4xx только когда точно не набирали")
|
||||
# ============================================================================
|
||||
nabor = PoddelnyyNabornik(ishod=Nabor.NE_NACHAT)
|
||||
p, _ = sobrat("o1.sqlite3", nabornik=nabor)
|
||||
kod, otvet, _zh = poprosit(p, "pozvonit", zadanie("z-ne-nachat"), znak="z-ne-nachat")
|
||||
v_predelah("набор поклялся «не начинал» — можно 4xx", kod, 400, 500)
|
||||
ravno("и в теле сказано «нет»", otvet.get("ok"), False)
|
||||
kod2, _o, _zh = poprosit(p, "pozvonit", zadanie("z-ne-nachat"), znak="z-ne-nachat")
|
||||
ravno("после честного «не набирал» повтор МОЖЕТ набрать снова", nabor.naborov, 2)
|
||||
|
||||
nabor = PoddelnyyNabornik(ishod=Nabor.NE_ZNAYU)
|
||||
p, _ = sobrat("o2.sqlite3", nabornik=nabor)
|
||||
kod, _o, _zh = poprosit(p, "pozvonit", zadanie("z-ne-znayu"), znak="z-ne-znayu")
|
||||
v_predelah("набор сказал «не знаю» — только 5xx", kod, 500, 600)
|
||||
kod2, _o, _zh = poprosit(p, "pozvonit", zadanie("z-ne-znayu"), znak="z-ne-znayu")
|
||||
ravno("после «не знаю» повтор НЕ набирает снова", nabor.naborov, 1)
|
||||
v_predelah("и повтору отвечают тем же «не знаю»", kod2, 500, 600)
|
||||
|
||||
nabor = PoddelnyyNabornik(ronyat=True)
|
||||
p, _ = sobrat("o3.sqlite3", nabornik=nabor)
|
||||
kod, _o, zhurnal = poprosit(p, "pozvonit", zadanie("z-upal"), znak="z-upal")
|
||||
v_predelah("🔴 набор упал исключением — 5xx, а не 4xx", kod, 500, 600)
|
||||
kod2, _o, _zh = poprosit(p, "pozvonit", zadanie("z-upal"), znak="z-upal")
|
||||
ravno("после падения набора повтор НЕ набирает снова", nabor.naborov, 1)
|
||||
if "RuntimeError" in zhurnal:
|
||||
zelenyy("падение набора названо в журнале — не проглочено молча")
|
||||
else:
|
||||
krasnyy(f"падение набора в журнале не названо. Журнал: {zhurnal.strip()}")
|
||||
|
||||
nabor = PoddelnyyNabornik()
|
||||
p, _ = sobrat("o4.sqlite3", nabornik=nabor)
|
||||
kod, _o, _zh = poprosit(p, "pozvonit", {"zadanie": "z-bez-tel"}, znak="z-bez-tel")
|
||||
ravno("задание без номера — отказ", kod, 400)
|
||||
kod, _o, _zh = poprosit(p, "pozvonit", {"telefon": TAYNYY_TELEFON}, znak=None)
|
||||
ravno("задание без опознавательного знака — отказ", kod, 400)
|
||||
ravno("🔴 при отказах ДО набора набор не звали ни разу", nabor.naborov, 0)
|
||||
kod, _o, _zh = poprosit(p, "pozvonit", zadanie("z-nesh"), znak="z-drugoy")
|
||||
ravno("знак в теле и в заголовке разный — отказ", kod, 400)
|
||||
kod, _o, _zh = poprosit(p, "pozvonit", zadanie("z-gg"), znak="z-gg",
|
||||
vtoraya_metka="z-podmena")
|
||||
ravno("два заголовка с разными метками — отказ", kod, 400)
|
||||
ravno("и на этих отказах набор тоже не звали", nabor.naborov, 0)
|
||||
print()
|
||||
|
||||
# ============================================================================
|
||||
print("7. Ручки состояния и остановки — и что память ЗНАКОВ к ним не липнет")
|
||||
# ============================================================================
|
||||
nabor = PoddelnyyNabornik()
|
||||
p, _ = sobrat("st.sqlite3", nabornik=nabor)
|
||||
_k, otvet, _zh = poprosit(p, "pozvonit", zadanie("z-sost"), znak="z-sost")
|
||||
call_id = otvet["call_id"]
|
||||
|
||||
kod, _o, _zh = poprosit(p, "sostoyanie", {"call_id": "нет-такого"}, znak="нет-такого")
|
||||
ravno("состояние неизвестного звонка — 404", kod, 404)
|
||||
kod, otvet1, _zh = poprosit(p, "sostoyanie", {"call_id": call_id}, znak=call_id)
|
||||
ravno("состояние известного звонка — 200", kod, 200)
|
||||
ravno("и в теле условленное слово", otvet1.get("ok"), True)
|
||||
ravno("состояние звонка названо", otvet1.get("sostoyanie"), "nabiraem")
|
||||
|
||||
kod, _o, _zh = poprosit(p, "ostanovit", {"call_id": "нет-такого", "prichina": "проба"},
|
||||
znak="нет-такого")
|
||||
ravno("остановка неизвестного звонка — 404", kod, 404)
|
||||
kod, otvet2, _zh = poprosit(p, "ostanovit", {"call_id": call_id, "prichina": "проба"},
|
||||
znak=call_id)
|
||||
ravno("остановка известного звонка — 200", kod, 200)
|
||||
|
||||
kod, otvet3, _zh = poprosit(p, "sostoyanie", {"call_id": call_id}, znak=call_id)
|
||||
if otvet3.get("sostoyanie") == "ostanovka_poproshena":
|
||||
zelenyy("🔴 второй вопрос о состоянии отдал НОВОЕ состояние, а не запомненный ответ")
|
||||
else:
|
||||
krasnyy("🔴🔴 состояние ЗАЛИПЛО на первом ответе — память знаков применена не к той ручке "
|
||||
f"(получено {otvet3.get('sostoyanie')!r})")
|
||||
ravno("набор от вопросов о состоянии не тревожили", nabor.naborov, 1)
|
||||
print()
|
||||
|
||||
# ============================================================================
|
||||
print("8. Личные данные: журнал и память знаков")
|
||||
# ============================================================================
|
||||
nabor = PoddelnyyNabornik()
|
||||
p, pam = sobrat("pdn.sqlite3", nabornik=nabor)
|
||||
_k, _o, zhurnal = poprosit(p, "pozvonit", zadanie("z-pdn"), znak="z-pdn")
|
||||
_k2, _o2, zhurnal2 = poprosit(p, "pozvonit", zadanie("z-pdn"), znak="z-pdn")
|
||||
ves_zhurnal = zhurnal + zhurnal2
|
||||
|
||||
for kusok, chto in ((TAYNYY_TELEFON, "телефон человека"),
|
||||
("ВЕРБЛЮД", "кусок телефона"),
|
||||
(TAYNOE_IMYA, "имя человека"),
|
||||
("Апполинариевич", "кусок имени"),
|
||||
(TAYNYY_SKRIPT, "текст скрипта"),
|
||||
("АРБУЗ", "кусок скрипта"),
|
||||
(KLYUCH, "тайный ключ"),
|
||||
("tridtsati-znakov", "кусок тайного ключа")):
|
||||
if kusok in ves_zhurnal:
|
||||
krasnyy(f"🔴 В ЖУРНАЛ ПОПАЛ {chto}")
|
||||
print(" | " + ves_zhurnal.strip().replace("\n", "\n | "))
|
||||
else:
|
||||
zelenyy(f"{chto} в журнал не попал")
|
||||
|
||||
if "z-pdn" in ves_zhurnal:
|
||||
zelenyy("опознавательный знак в журнале есть — наладке есть за что зацепиться")
|
||||
else:
|
||||
krasnyy("знака в журнале нет — по журналу не найти задание")
|
||||
|
||||
pam.svyaz.commit()
|
||||
with open(pam.put, "rb") as f:
|
||||
bayty_pamyati = f.read()
|
||||
for kusok, chto in ((TAYNYY_TELEFON, "телефон"), (TAYNOE_IMYA, "имя"),
|
||||
(TAYNYY_SKRIPT, "скрипт"), (KLYUCH, "тайный ключ")):
|
||||
if kusok.encode("utf-8") in bayty_pamyati:
|
||||
krasnyy(f"🔴 В ФАЙЛЕ ПАМЯТИ ЗНАКОВ ЛЕЖИТ {chto}")
|
||||
else:
|
||||
zelenyy(f"{chto} в файле памяти знаков не лежит")
|
||||
print()
|
||||
|
||||
# ============================================================================
|
||||
print("9. Живая оболочка на сокете — настоящий HTTP, без телефонии")
|
||||
# ============================================================================
|
||||
nabor = PoddelnyyNabornik()
|
||||
pam_s = Pamyat(put=os.path.join(VREMENNAYA, "socket.sqlite3"))
|
||||
server = priyomnik.podnyat(port=0, adres="127.0.0.1", pamyat=pam_s,
|
||||
nabornik=nabor, klyuch=KLYUCH)
|
||||
port = server.server_address[1]
|
||||
potok = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
byl = sys.stdout
|
||||
sys.stdout = io.StringIO() # журнал сервера в общий вывод не пускаем
|
||||
potok.start()
|
||||
|
||||
|
||||
def sprosit_po_seti(ruchka, telo, klyuch=KLYUCH, sposob="POST", dlinnoe=False):
|
||||
dannye = json.dumps(telo, ensure_ascii=False).encode("utf-8")
|
||||
if dlinnoe:
|
||||
dannye = b'{"zadanie":"z-tolstoe","telefon":"' + b"9" * 100000 + b'"}'
|
||||
zapros = urllib.request.Request(f"http://127.0.0.1:{port}/{ruchka}",
|
||||
data=dannye if sposob == "POST" else None,
|
||||
method=sposob)
|
||||
zapros.add_header("Content-Type", "application/json")
|
||||
if klyuch is not None:
|
||||
zapros.add_header("X-Obzvon-Key", klyuch)
|
||||
zapros.add_header("X-Obzvon-Zadanie", str(telo.get("zadanie") or telo.get("call_id") or ""))
|
||||
zapros.add_header("Idempotency-Key", str(telo.get("zadanie") or telo.get("call_id") or ""))
|
||||
try:
|
||||
with urllib.request.urlopen(zapros, timeout=10) as o:
|
||||
return o.status, json.loads(o.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
syroe = e.read().decode("utf-8")
|
||||
try:
|
||||
return e.code, json.loads(syroe)
|
||||
except ValueError:
|
||||
return e.code, {}
|
||||
|
||||
|
||||
try:
|
||||
kod, otvet = sprosit_po_seti("pozvonit", zadanie("z-set-1"))
|
||||
sys.stdout = byl
|
||||
ravno("настоящий POST по сети — принято", kod, 200)
|
||||
ravno("и в теле условленное слово ok", otvet.get("ok"), True)
|
||||
if otvet.get("ok") is True:
|
||||
zelenyy("ok — именно истина, а не строка «true» и не единица")
|
||||
else:
|
||||
krasnyy(f"ok пришло как {otvet.get('ok')!r} — портал такое успехом не считает")
|
||||
sys.stdout = io.StringIO()
|
||||
|
||||
kod, _o = sprosit_po_seti("pozvonit", zadanie("z-set-2"), klyuch=CHUZHOY_KLYUCH)
|
||||
sys.stdout = byl
|
||||
ravno("по сети с чужим ключом — 401", kod, 401)
|
||||
sys.stdout = io.StringIO()
|
||||
|
||||
kod, _o = sprosit_po_seti("pozvonit", zadanie("z-set-3"), sposob="GET")
|
||||
sys.stdout = byl
|
||||
ravno("GET по ручке звонка — 405, звонок так не начать", kod, 405)
|
||||
sys.stdout = io.StringIO()
|
||||
|
||||
kod, _o = sprosit_po_seti("pozvonit", {}, dlinnoe=True)
|
||||
sys.stdout = byl
|
||||
ravno("тело сверх предела — 413", kod, 413)
|
||||
sys.stdout = io.StringIO()
|
||||
|
||||
kod, _o = sprosit_po_seti("neizvestnaya", zadanie("z-set-4"))
|
||||
sys.stdout = byl
|
||||
ravno("неизвестная ручка — 404", kod, 404)
|
||||
sys.stdout = io.StringIO()
|
||||
|
||||
kod, _o = sprosit_po_seti("pozvonit", zadanie("z-set-1"))
|
||||
sys.stdout = byl
|
||||
ravno("повтор ПО СЕТИ — второго набора нет", nabor.naborov, 1)
|
||||
ravno("и по сети повтор отвечает так же", kod, 200)
|
||||
finally:
|
||||
sys.stdout = byl
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
pam_s.zakryt()
|
||||
print()
|
||||
|
||||
# ============================================================================
|
||||
print("10. Р84: чем поднят и сколько стоит")
|
||||
# ============================================================================
|
||||
# 🪤 Ввозы разбираются деревом разбора (`ast`), а не поиском по строкам: строка
|
||||
# «import flask» внутри пояснения или строки-заготовки посчиталась бы ввозом,
|
||||
# которого нет, а хитро записанный настоящий ввоз — не посчитался бы вовсе.
|
||||
vvozy = set()
|
||||
for uzel in ast.walk(ast.parse(ISHODNIK)):
|
||||
if isinstance(uzel, ast.Import):
|
||||
vvozy.update(a.name.split(".")[0] for a in uzel.names)
|
||||
elif isinstance(uzel, ast.ImportFrom) and uzel.module and uzel.level == 0:
|
||||
vvozy.add(uzel.module.split(".")[0])
|
||||
chuzhie = sorted(v for v in vvozy if v not in sys.stdlib_module_names)
|
||||
if chuzhie:
|
||||
krasnyy(f"🔴 приёмник тянет сторонние библиотеки: {', '.join(chuzhie)}")
|
||||
else:
|
||||
zelenyy(f"сторонних библиотек ноль, всё своё: {', '.join(sorted(vvozy))}")
|
||||
|
||||
for zapreshcheno in ("flask", "fastapi", "uvicorn", "aiohttp", "django", "requests"):
|
||||
if zapreshcheno in vvozy:
|
||||
krasnyy(f"🔴 поднято на {zapreshcheno} — тяжело для машины с боевым рендером")
|
||||
if not (vvozy & {"flask", "fastapi", "uvicorn", "aiohttp", "django", "requests"}):
|
||||
zelenyy("ни одного тяжёлого веб-каркаса — только http.server из самого Python")
|
||||
|
||||
nabor = PoddelnyyNabornik()
|
||||
p, _ = sobrat("cena.sqlite3", nabornik=nabor)
|
||||
SCHYOT_SQL[0] = 0
|
||||
tracemalloc.start()
|
||||
nachalo = tracemalloc.take_snapshot()
|
||||
for i in range(200):
|
||||
poprosit(p, "pozvonit", zadanie(f"z-cena-{i}"), znak=f"z-cena-{i}")
|
||||
pik = tracemalloc.get_traced_memory()[1]
|
||||
tracemalloc.stop()
|
||||
sql_na_zapros = SCHYOT_SQL[0] / 200
|
||||
pik_mb = pik / 1024 / 1024
|
||||
|
||||
if pik_mb < 8:
|
||||
zelenyy(f"пик памяти на 200 заданиях подряд — {pik_mb:.2f} МБ")
|
||||
else:
|
||||
krasnyy(f"🔴 пик памяти на 200 заданиях — {pik_mb:.2f} МБ, для чужого железа много")
|
||||
if sql_na_zapros <= 20:
|
||||
zelenyy(f"обращений к базе на одно задание — {sql_na_zapros:.1f}")
|
||||
else:
|
||||
krasnyy(f"🔴 обращений к базе на одно задание {sql_na_zapros:.1f} — слишком много")
|
||||
ravno("все 200 заданий разошлись по своим наборам", nabor.naborov, 200)
|
||||
print()
|
||||
|
||||
# ============================================================================
|
||||
print("11. 🔴 Знак ТОТ ЖЕ, а тело задания ДРУГОЕ")
|
||||
# ============================================================================
|
||||
# 🔴 Дыра, замеренная владельцем 07.08.2026 на живом приёмнике. Защита стояла на
|
||||
# допущении «повтор знака = повтор задания». Годный по форме случай мимо этого
|
||||
# допущения: знак тот же, а телефон в теле другой. Приёмник считал это повтором,
|
||||
# ВТОРОМУ ЧЕЛОВЕКУ не звонил вовсе и молча отдавал номер звонка ПЕРВОГО.
|
||||
#
|
||||
# 🪤 Как НЕ надо было сторожить: искать в тексте приёмника слово «отпечаток».
|
||||
# Ровно на этом уже погорели 07.08.2026 (см. пояснение в разделе 1). Здесь
|
||||
# проверяется РАБОТА: сколько раз позвали набор и что вернули порталу.
|
||||
|
||||
# -- А (контроль): то же тело дважды. Так было и так должно остаться.
|
||||
nabor = PoddelnyyNabornik()
|
||||
p, _ = sobrat("telo-a.sqlite3", nabornik=nabor)
|
||||
kod_a1, otvet_a1, _zh = poprosit(p, "pozvonit", zadanie("z-odin"), znak="z-odin")
|
||||
kod_a2, otvet_a2, _zh = poprosit(p, "pozvonit", zadanie("z-odin"), znak="z-odin")
|
||||
ravno("А: первое задание принято", kod_a1, 200)
|
||||
ravno("А: то же тело второй раз — набор позван РОВНО ОДИН раз", nabor.naborov, 1)
|
||||
ravno("А: и это повтор", otvet_a2.get("povtor"), True)
|
||||
ravno("А: с тем же номером звонка", otvet_a2.get("call_id"), otvet_a1.get("call_id"))
|
||||
|
||||
# -- А2: то же тело, но поля переставлены местами. Это ТО ЖЕ тело.
|
||||
# Иначе отпечаток зависел бы от порядка ключей в JSON, а порядок не значит
|
||||
# ничего — его волен менять кто угодно по дороге.
|
||||
naiznanku = dict(reversed(list(zadanie("z-odin").items())))
|
||||
kod_a3, otvet_a3, _zh = poprosit(p, "pozvonit", naiznanku, znak="z-odin")
|
||||
ravno("А2: порядок полей переставлен — всё равно повтор, набора нет", nabor.naborov, 1)
|
||||
ravno("А2: и код тот же", kod_a3, kod_a1)
|
||||
ravno("А2: и ответ тот же", otvet_a3.get("call_id"), otvet_a1.get("call_id"))
|
||||
|
||||
# -- Б (нож): тот же знак, ДРУГОЙ телефон.
|
||||
nabor_b = PoddelnyyNabornik()
|
||||
p_b, pam_b = sobrat("telo-b.sqlite3", nabornik=nabor_b)
|
||||
kod_b1, otvet_b1, _zh = poprosit(p_b, "pozvonit", zadanie("z-odin"), znak="z-odin")
|
||||
kod_b2, otvet_b2, zhurnal_b = poprosit(
|
||||
p_b, "pozvonit", zadanie("z-odin", telefon=DRUGOY_TELEFON), znak="z-odin")
|
||||
ravno("Б: первое задание принято", kod_b1, 200)
|
||||
v_predelah("🔴 Б: тот же знак с ДРУГИМ телом — приёмник РУГАЕТСЯ, а не молчит",
|
||||
kod_b2, 400, 500)
|
||||
ravno("Б: и в теле сказано «нет»", otvet_b2.get("ok"), False)
|
||||
if otvet_b2.get("call_id") == otvet_b1.get("call_id"):
|
||||
krasnyy("🔴🔴 Б: второму заданию отдан номер звонка ПЕРВОГО — "
|
||||
"портал привяжет чужой разговор")
|
||||
else:
|
||||
zelenyy("🔴 Б: номер звонка первого задания второму НЕ отдан")
|
||||
if otvet_b2.get("povtor") is True:
|
||||
krasnyy("🔴 Б: расхождение названо «повтором» — это не повтор, а ошибка портала")
|
||||
else:
|
||||
zelenyy("Б: расхождение повтором не названо")
|
||||
ravno("Б: набор на расхождении не звали", nabor_b.naborov, 1)
|
||||
|
||||
# И самое важное: расписка ПЕРВОГО задания цела. Иначе лечение стоило бы
|
||||
# второго звонка тому, кому уже позвонили.
|
||||
kod_b3, otvet_b3, _zh = poprosit(p_b, "pozvonit", zadanie("z-odin"), znak="z-odin")
|
||||
ravno("Б: после расхождения прежнее тело всё ещё узнаётся повтором",
|
||||
otvet_b3.get("call_id"), otvet_b1.get("call_id"))
|
||||
ravno("Б: и набор так и позван один раз", nabor_b.naborov, 1)
|
||||
|
||||
for kusok, chto in ((TAYNYY_TELEFON, "телефон первого"),
|
||||
(DRUGOY_TELEFON, "телефон второго"),
|
||||
("ЖИРАФ", "кусок второго телефона")):
|
||||
if kusok in zhurnal_b:
|
||||
krasnyy(f"🔴 Б: В ЖУРНАЛ РАСХОЖДЕНИЯ ПОПАЛ {chto}")
|
||||
else:
|
||||
zelenyy(f"Б: {chto} в журнал расхождения не попал")
|
||||
if "z-odin" in zhurnal_b:
|
||||
zelenyy("Б: знак в журнале назван — наладке есть за что зацепиться")
|
||||
else:
|
||||
krasnyy(f"Б: знака в журнале нет. Журнал: {zhurnal_b.strip()}")
|
||||
|
||||
pam_b.svyaz.commit()
|
||||
with open(pam_b.put, "rb") as f:
|
||||
bayty_b = f.read()
|
||||
for kusok, chto in ((TAYNYY_TELEFON, "телефон первого"),
|
||||
(DRUGOY_TELEFON, "телефон второго")):
|
||||
if kusok.encode("utf-8") in bayty_b:
|
||||
krasnyy(f"🔴 Б: В ФАЙЛЕ ПАМЯТИ ЛЕЖИТ {chto} — отпечаток открытым текстом")
|
||||
else:
|
||||
zelenyy(f"Б: {chto} в файле памяти не лежит")
|
||||
|
||||
# -- В: то же тело, но ПРИБАВИЛОСЬ лишнее поле.
|
||||
# Решение: это тоже расхождение. Список полей отпечатка считается
|
||||
# ВЫЧИТАНИЕМ, поэтому поле, о котором сегодня никто не знает, оказывается
|
||||
# ПОД защитой, а не мимо неё.
|
||||
nabor_v = PoddelnyyNabornik()
|
||||
p_v, _ = sobrat("telo-v.sqlite3", nabornik=nabor_v)
|
||||
kod_v1, otvet_v1, _zh = poprosit(p_v, "pozvonit", zadanie("z-odin"), znak="z-odin")
|
||||
s_lishnim = dict(zadanie("z-odin"), zapasnoy_telefon=DRUGOY_TELEFON)
|
||||
kod_v2, otvet_v2, _zh = poprosit(p_v, "pozvonit", s_lishnim, znak="z-odin")
|
||||
ravno("В: первое задание принято", kod_v1, 200)
|
||||
v_predelah("🔴 В: прибавилось поле, о котором приёмник не знает — расхождение",
|
||||
kod_v2, 400, 500)
|
||||
ravno("В: набор на лишнем поле не звали", nabor_v.naborov, 1)
|
||||
if otvet_v2.get("call_id") == otvet_v1.get("call_id"):
|
||||
krasnyy("🔴 В: и при лишнем поле отдан чужой номер звонка")
|
||||
else:
|
||||
zelenyy("В: чужой номер звонка при лишнем поле не отдан")
|
||||
|
||||
# -- Г: первое ЕЩЁ В РАБОТЕ, а второе пришло с другим телом.
|
||||
# Это доказывает, что отпечаток запоминается при БРОНИ, а не при ответе:
|
||||
# иначе сравнивать было бы не с чем и второй получил бы «не знаю».
|
||||
nabor_g = PoddelnyyNabornik(zaderzhka=0.4)
|
||||
p_g, _ = sobrat("telo-g.sqlite3", nabornik=nabor_g)
|
||||
itogi_g = []
|
||||
|
||||
|
||||
def gonets_svoyo():
|
||||
itogi_g.append(("svoyo",) + poprosit(p_g, "pozvonit", zadanie("z-odin"), znak="z-odin")[:2])
|
||||
|
||||
|
||||
def gonets_chuzhoe():
|
||||
itogi_g.append(("chuzhoe",) + poprosit(
|
||||
p_g, "pozvonit", zadanie("z-odin", telefon=DRUGOY_TELEFON), znak="z-odin")[:2])
|
||||
|
||||
|
||||
potoki_g = [threading.Thread(target=gonets_svoyo), threading.Thread(target=gonets_chuzhoe)]
|
||||
for t in potoki_g:
|
||||
t.start()
|
||||
time.sleep(0.05)
|
||||
for t in potoki_g:
|
||||
t.join()
|
||||
chuzhoy_kod = [k for imya, k, _o in itogi_g if imya == "chuzhoe"][0]
|
||||
v_predelah("🔴 Г: другое тело, пока первое ещё в работе — тоже расхождение",
|
||||
chuzhoy_kod, 400, 500)
|
||||
ravno("Г: и набор позван один раз", nabor_g.naborov, 1)
|
||||
print()
|
||||
|
||||
# ============================================================================
|
||||
print("12. Уборка за собой")
|
||||
# ============================================================================
|
||||
for pamyat in PAMYATI:
|
||||
try:
|
||||
pamyat.zakryt()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
shutil.rmtree(VREMENNAYA, ignore_errors=True)
|
||||
if os.path.exists(VREMENNAYA):
|
||||
krasnyy(f"временная папка не убрана: {VREMENNAYA}")
|
||||
else:
|
||||
zelenyy("временная папка пробы убрана")
|
||||
print()
|
||||
|
||||
print("──────────────────────────────────────────────")
|
||||
ZELYONYH = PROVERENO - PROVALOV
|
||||
print(f"проверок {PROVERENO} = зелёных {ZELYONYH} + красных {PROVALOV}")
|
||||
if ZELYONYH + PROVALOV != PROVERENO:
|
||||
print("🔴 АРИФМЕТИКА НЕ СОШЛАСЬ — прибору верить нельзя")
|
||||
sys.exit(1)
|
||||
if PROVALOV == 0:
|
||||
print(f"✅ ЦЕЛО: проверок {PROVERENO}, красных 0")
|
||||
sys.exit(0)
|
||||
print(f"🔴 БЕДА: проверок {PROVERENO}, красных {PROVALOV}")
|
||||
sys.exit(1)
|
||||
@@ -44,7 +44,28 @@ zelenyy() { PROVERENO=$(( PROVERENO + 1 )); echo " ✅ $1"; }
|
||||
krasnyy() { PROVERENO=$(( PROVERENO + 1 )); PROVALOV=$(( PROVALOV + 1 )); echo " 🔴 $1"; }
|
||||
|
||||
# Одна дверь на сервер: и проще читать, и легче подменить при пробе.
|
||||
na_servere() { ssh -o BatchMode=yes -o ConnectTimeout=20 -i "$KLYUCH" "$SERVER" "$1" 2>/dev/null; }
|
||||
#
|
||||
# 🔴 У двери две створки. Обычная — по ssh с рабочего места. Вторая, «местная»
|
||||
# (LENA_MESTNAYA=1), означает «мы УЖЕ на этой машине»: команды идут прямо
|
||||
# здесь, без ssh. Она нужна суточному обходу sutochnyy-obhod.sh, который
|
||||
# машина запускает сама по расписанию: закрытого ssh-ключа у машины нет, и
|
||||
# заводить его нельзя — ключ от самой себя на машине, где лежат голоса
|
||||
# живых людей, это дыра, а не удобство.
|
||||
#
|
||||
# 🔴 Что при местной створке ТЕРЯЕТСЯ, говорим вслух, а не умалчиваем:
|
||||
# - проверка 1 «достучались до машины» становится бессодержательной:
|
||||
# раз мы исполняемся, машина жива по определению. Смерть машины целиком
|
||||
# этим сторожем больше не ловится — её ловит портал по ВОЗРАСТУ итога;
|
||||
# - эталоны берутся из машинной копии папки, а не из хранилища. Совпадение
|
||||
# машинной копии с хранилищем этот сторож доказать не может — это
|
||||
# проверяет отдельный сторож proverka-sutochnogo-obhoda.sh, и он ходит
|
||||
# с рабочего места по ssh, то есть смотрит на обе стороны сразу.
|
||||
if [ "${LENA_MESTNAYA:-0}" = "1" ]; then
|
||||
SERVER="сама машина (без ssh)"
|
||||
na_servere() { bash -c "$1" 2>/dev/null; }
|
||||
else
|
||||
na_servere() { ssh -o BatchMode=yes -o ConnectTimeout=20 -i "$KLYUCH" "$SERVER" "$1" 2>/dev/null; }
|
||||
fi
|
||||
|
||||
echo "Сторож уборки на сервере робота. Сервер: $SERVER"
|
||||
echo
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
#!/bin/bash
|
||||
# Сторож СУТОЧНОГО ОБХОДА — тот, кто следит за следящим.
|
||||
#
|
||||
# Чем он отличается от остальных. Остальные сторожа проверяют уборку записей.
|
||||
# Этот проверяет ОБХОД — то, что заставляет остальных сторожей ходить самих,
|
||||
# без человека. Обход — новый молчун: умри он тихо, последний зелёный итог
|
||||
# останется лежать на месте, и всё будет выглядеть как «хорошо», пока голоса
|
||||
# чужих людей копятся месяцами.
|
||||
#
|
||||
# 🔴 Он ходит С РАБОЧЕГО МЕСТА по ssh, а не с машины, и это принципиально.
|
||||
# Обход на машине сверяет живые файлы с МАШИННОЙ копией эталонов. Разойдись
|
||||
# машинная копия с хранилищем — обход этого не увидит: он сравнил бы копию
|
||||
# сам с собой. Круговую поруку разрывает только взгляд снаружи, и это здесь.
|
||||
#
|
||||
# 🔴 Ничего на машине не удаляет и не портит (решения владельца Р94, Р84).
|
||||
# Проба обхода идёт в своей временной папке на подставных сторожах.
|
||||
#
|
||||
# Запуск одной командой:
|
||||
# bash bots/lena-golos/proverka-sutochnogo-obhoda.sh
|
||||
#
|
||||
# Настройки — переменными окружения:
|
||||
# LENA_SERVER куда ходить (по умолчанию ubuntu@51.250.1.97)
|
||||
# LENA_KLYUCH каким ключом (по умолчанию ~/.ssh/liderra_deploy)
|
||||
# LENA_STOROZHA_SKOLKO сколько сторожей ждём (по умолчанию 3 — как в портале)
|
||||
# LENA_SVEZHEST_CHASOV сколько часов итог считается свежим (по умолчанию 26)
|
||||
#
|
||||
# Возвращает 0, если всё цело, и 1, если хоть одна проверка покраснела.
|
||||
|
||||
set -u
|
||||
|
||||
ZDES="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
SERVER="${LENA_SERVER:-ubuntu@51.250.1.97}"
|
||||
KLYUCH="${LENA_KLYUCH:-$HOME/.ssh/liderra_deploy}"
|
||||
OZHIDAEM="${LENA_STOROZHA_SKOLKO:-3}"
|
||||
SVEZHEST="${LENA_SVEZHEST_CHASOV:-26}"
|
||||
|
||||
PAPKA_TAM="/home/ubuntu/lena-golos"
|
||||
RASPISANIE_TAM="/etc/cron.d/lena-sutochnyy-obhod"
|
||||
OTCHYOT_TAM="$PAPKA_TAM/otchyot-storozhey.json"
|
||||
NASTROYKI_TAM="$PAPKA_TAM/nastroyki-obhoda"
|
||||
|
||||
# 🔴 Полный состав машинной копии. Ни больше, ни меньше. Лишний proverka-*.sh
|
||||
# в этой папке обход подхватит и погонит — поэтому «лишних быть не должно»
|
||||
# здесь такая же проверка, как «нужные на месте».
|
||||
ETALONY=(
|
||||
sutochnyy-obhod.sh
|
||||
proverka-raspisaniya.sh
|
||||
proverka-tekstovogo-sleda.sh
|
||||
proverka-chistki.sh
|
||||
chistka-zapisey.sh
|
||||
chistka-zapisey.cron
|
||||
chistka-sleda-prob.sh
|
||||
chistka-sleda-prob.cron
|
||||
lena-inworld.conf
|
||||
tekstovyy-sled.logrotate
|
||||
most.py
|
||||
proba-mosta-bez-zvonka.py
|
||||
)
|
||||
|
||||
PROVALOV=0
|
||||
PROVERENO=0
|
||||
|
||||
zelenyy() { PROVERENO=$(( PROVERENO + 1 )); echo " ✅ $1"; }
|
||||
krasnyy() { PROVERENO=$(( PROVERENO + 1 )); PROVALOV=$(( PROVALOV + 1 )); echo " 🔴 $1"; }
|
||||
|
||||
na_servere() { ssh -o BatchMode=yes -o ConnectTimeout=20 -i "$KLYUCH" "$SERVER" "$1" 2>/dev/null; }
|
||||
|
||||
# Побайтовая сверка со снятием \r: рабочее дерево на Windows добавляет его сам,
|
||||
# и сырое сравнение слепков здесь врёт — замерено не один раз.
|
||||
sverit_pobaytno() { # <файл здесь> <файл там> <как называется>
|
||||
local sha_tam sha_zdes
|
||||
sha_tam="$(na_servere "tr -d '\r' < '$2' 2>/dev/null | sha256sum | cut -d' ' -f1")"
|
||||
sha_zdes="$(tr -d '\r' < "$1" 2>/dev/null | sha256sum | cut -d' ' -f1)"
|
||||
if [ -n "$sha_tam" ] && [ "$sha_tam" = "$sha_zdes" ]; then
|
||||
zelenyy "$3: текст на машине и в хранилище один и тот же"
|
||||
else
|
||||
krasnyy "$3: текст РАЗОШЁЛСЯ — машина ${sha_tam:-(файла нет)}, хранилище ${sha_zdes:-(файла нет)}"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Сторож суточного обхода. Машина: $SERVER"
|
||||
echo
|
||||
|
||||
# ------------------------------------------------------- 1. дверь ----------
|
||||
echo "1. Достучались ли до машины"
|
||||
if [ "$(na_servere 'echo zhiv')" = "zhiv" ]; then
|
||||
zelenyy "машина отвечает"
|
||||
else
|
||||
krasnyy "машина НЕ отвечает — дальше мерить нечем"
|
||||
echo "──────────────────────────────────────────────"
|
||||
echo "🔴 БЕДА: проверок $PROVERENO, красных $PROVALOV"
|
||||
exit 1
|
||||
fi
|
||||
echo
|
||||
|
||||
# --------------------------------------------- 2. расписание обхода --------
|
||||
echo "2. Расписание: зовёт ли обход хоть кто-нибудь"
|
||||
|
||||
if [ "$(na_servere "test -f '$RASPISANIE_TAM' && echo est")" = "est" ]; then
|
||||
zelenyy "файл расписания на месте: $RASPISANIE_TAM"
|
||||
else
|
||||
krasnyy "файла расписания НЕТ: $RASPISANIE_TAM — обход не зовёт НИКТО"
|
||||
fi
|
||||
|
||||
sverit_pobaytno "$ZDES/sutochnyy-obhod.cron" "$RASPISANIE_TAM" "расписание обхода"
|
||||
|
||||
# 🔴 cron берёт из /etc/cron.d только имена из букв, цифр, подчёркиваний и
|
||||
# дефисов. Точка в имени — и файл МОЛЧА не исполняется никогда.
|
||||
IMYA_FAYLA="$(basename "$RASPISANIE_TAM")"
|
||||
if printf '%s' "$IMYA_FAYLA" | grep -qE '^[A-Za-z0-9_-]+$'; then
|
||||
zelenyy "имя годное для cron: «$IMYA_FAYLA» — без точки"
|
||||
else
|
||||
krasnyy "имя «$IMYA_FAYLA» НЕ годится: cron молча пропустит такой файл"
|
||||
fi
|
||||
|
||||
VLADELETS="$(na_servere "stat -c '%U %a' '$RASPISANIE_TAM' 2>/dev/null")"
|
||||
if [ "$VLADELETS" = "root 644" ]; then
|
||||
zelenyy "владелец и права расписания: $VLADELETS"
|
||||
else
|
||||
krasnyy "владелец/права расписания «${VLADELETS:-непонятно}», ждали «root 644»"
|
||||
fi
|
||||
echo
|
||||
|
||||
# --------------------------------------------- 3. машинная копия -----------
|
||||
echo "3. Машинная копия эталонов: та же ли она, что в хранилище"
|
||||
echo " 🔴 Это и есть тот взгляд снаружи, которого у обхода на машине нет."
|
||||
|
||||
if [ "$(na_servere "test -d '$PAPKA_TAM' && echo est")" = "est" ]; then
|
||||
zelenyy "папка обхода на месте: $PAPKA_TAM"
|
||||
else
|
||||
krasnyy "папки обхода НЕТ: $PAPKA_TAM"
|
||||
fi
|
||||
|
||||
for F in "${ETALONY[@]}"; do
|
||||
if [ ! -f "$ZDES/$F" ]; then
|
||||
krasnyy "эталона нет в ХРАНИЛИЩЕ: $F"
|
||||
continue
|
||||
fi
|
||||
sverit_pobaytno "$ZDES/$F" "$PAPKA_TAM/$F" "$F"
|
||||
done
|
||||
|
||||
# Лишние файлы. Своё рабочее (итог, журналы, настройки) не считаем.
|
||||
LISHNIE="$(na_servere "cd '$PAPKA_TAM' 2>/dev/null && ls -1A | grep -vE '^(otchyot-storozhey\.json|sutochnyy-obhod\.log|sutochnyy-obhod\.log\.otvet|nastroyki-obhoda)\$'")"
|
||||
OZHIDAEMYE="$(printf '%s\n' "${ETALONY[@]}" | LC_ALL=C sort)"
|
||||
FAKTICHESKIE="$(printf '%s\n' "$LISHNIE" | grep -v '^$' | LC_ALL=C sort)"
|
||||
if [ "$OZHIDAEMYE" = "$FAKTICHESKIE" ]; then
|
||||
zelenyy "в папке ровно ${#ETALONY[@]} эталонов, лишнего нет"
|
||||
else
|
||||
krasnyy "состав папки РАЗОШЁЛСЯ с хранилищем. На машине:"
|
||||
printf '%s\n' "$FAKTICHESKIE" | sed 's/^/ | /'
|
||||
fi
|
||||
|
||||
if [ "$(na_servere "test -x '$PAPKA_TAM/sutochnyy-obhod.sh' && echo da")" = "da" ]; then
|
||||
zelenyy "обход исполним"
|
||||
else
|
||||
krasnyy "обход НЕ исполним — расписание его не запустит"
|
||||
fi
|
||||
echo
|
||||
|
||||
# --------------------------------------------- 4. сколько сторожей ---------
|
||||
echo "4. Сколько сторожей обход найдёт — столько ли, сколько ждёт портал"
|
||||
|
||||
NASHLOS="$(na_servere "ls -1 '$PAPKA_TAM'/proverka-*.sh 2>/dev/null | wc -l")"
|
||||
if [ "${NASHLOS:-0}" = "$OZHIDAEM" ]; then
|
||||
zelenyy "сторожей в папке $NASHLOS — ровно столько, сколько ждёт портал"
|
||||
else
|
||||
krasnyy "сторожей в папке ${NASHLOS:-непонятно}, а портал ждёт $OZHIDAEM. Либо положили сторожа и забыли сказать порталу, либо сторож пропал"
|
||||
fi
|
||||
echo
|
||||
|
||||
# --------------------------------------------- 5. итог обхода --------------
|
||||
echo "5. Итог: есть ли он, свеж ли, и что в нём написано"
|
||||
|
||||
if [ "$(na_servere "test -f '$OTCHYOT_TAM' && echo est")" = "est" ]; then
|
||||
zelenyy "итог на месте: $OTCHYOT_TAM"
|
||||
|
||||
VOZRAST_SEK="$(na_servere "echo \$(( \$(date -u +%s) - \$(stat -c %Y '$OTCHYOT_TAM') ))")"
|
||||
PREDEL=$(( SVEZHEST * 3600 ))
|
||||
if [ -n "${VOZRAST_SEK:-}" ] && [ "$VOZRAST_SEK" -le "$PREDEL" ]; then
|
||||
zelenyy "итог свежий: $(( VOZRAST_SEK / 3600 )) ч, порог $SVEZHEST ч"
|
||||
else
|
||||
krasnyy "итог ПРОТУХ: $(( ${VOZRAST_SEK:-0} / 3600 )) ч, порог $SVEZHEST ч. Зелёный старый итог хуже красного свежего — он усыпляет"
|
||||
fi
|
||||
|
||||
SODERZHANIE="$(na_servere "cat '$OTCHYOT_TAM'")"
|
||||
for POLE in shema sobrano_v mashina storozhey_vsego storozhey_krasnyh slepok_etalonov storozha; do
|
||||
if printf '%s' "$SODERZHANIE" | grep -qF "\"$POLE\""; then
|
||||
zelenyy "в итоге есть поле «$POLE»"
|
||||
else
|
||||
krasnyy "в итоге НЕТ поля «$POLE» — портал такой итог не разберёт"
|
||||
fi
|
||||
done
|
||||
|
||||
V_ITOGE="$(printf '%s' "$SODERZHANIE" | sed -n 's/.*"storozhey_vsego": *\([0-9][0-9]*\).*/\1/p' | head -1)"
|
||||
if [ "${V_ITOGE:-нет}" = "$OZHIDAEM" ]; then
|
||||
zelenyy "итог называет $V_ITOGE сторожей — столько же, сколько ждёт портал"
|
||||
else
|
||||
krasnyy "итог называет ${V_ITOGE:-непонятно} сторожей, а портал ждёт $OZHIDAEM"
|
||||
fi
|
||||
else
|
||||
krasnyy "итога НЕТ вовсе: $OTCHYOT_TAM — обход ни разу не доходил до конца"
|
||||
fi
|
||||
echo
|
||||
|
||||
# --------------------------------------------- 6. тайное слово -------------
|
||||
echo "6. Тайное слово: лежит ли отдельно и закрыто ли от чужих глаз"
|
||||
|
||||
# 🪤 Образец нарочно собран из кусков, а НЕ написан одной строкой: написанный
|
||||
# целиком, он нашёл бы сам себя в этом же файле и сторож краснел бы вечно.
|
||||
# Ту же грабку в соседнем стороже автор обошёл скобками — здесь склейкой.
|
||||
OBRAZETS="TAYNOE_SLOVO""=[A-Za-z0-9_+/=-]\{8,\}"
|
||||
NASHLOS_SLOVO="$(grep -rIl --exclude='*.log' -- "$OBRAZETS" "$ZDES" 2>/dev/null)"
|
||||
if [ -z "$NASHLOS_SLOVO" ]; then
|
||||
zelenyy "в хранилище тайного слова нет — только имя переменной"
|
||||
else
|
||||
krasnyy "в хранилище нашлось похожее на записанное тайное слово — так нельзя:"
|
||||
printf '%s\n' "$NASHLOS_SLOVO" | sed 's/^/ | /'
|
||||
fi
|
||||
|
||||
if [ "$(na_servere "test -f '$NASTROYKI_TAM' && echo est")" = "est" ]; then
|
||||
PRAVA="$(na_servere "stat -c '%U %a' '$NASTROYKI_TAM'")"
|
||||
if [ "$PRAVA" = "ubuntu 600" ]; then
|
||||
zelenyy "настройки отправки закрыты: $PRAVA"
|
||||
else
|
||||
krasnyy "настройки отправки открыты шире, чем надо: «${PRAVA:-непонятно}», ждали «ubuntu 600»"
|
||||
fi
|
||||
|
||||
# 🔴 Файл на месте — ещё не значит, что дорога до портала есть. Пустые
|
||||
# PORTAL_URL или TAYNOE_SLOVO означают, что итог ложится файлом и НИКУДА
|
||||
# не уходит: портал его не увидит и молчание машины не заметит.
|
||||
# Слово отсюда НЕ читаем и не печатаем — только считаем, пусто или нет.
|
||||
ZAPOLNENO="$(na_servere "grep -cE '^(PORTAL_URL|TAYNOE_SLOVO)=.+' '$NASTROYKI_TAM'")"
|
||||
if [ "${ZAPOLNENO:-0}" = "2" ]; then
|
||||
zelenyy "дорога до портала настроена: адрес и слово на месте"
|
||||
else
|
||||
krasnyy "дорога до портала НЕ настроена: заполнено ${ZAPOLNENO:-0} строк из 2. Итог лежит файлом, портал его не получает"
|
||||
fi
|
||||
else
|
||||
krasnyy "настроек отправки на машине НЕТ — итог никуда не уходит, портал его не увидит"
|
||||
fi
|
||||
echo
|
||||
|
||||
# --------------------------------------------- 7. проба обхода -------------
|
||||
echo "7. Проба обхода на ПОДСТАВНЫХ сторожах"
|
||||
echo " 🔴 Настоящих сторожей здесь не ломаем: на машине руками не портим"
|
||||
echo " ничего (Р94). Обходу подкладываются заведомо негодные данные."
|
||||
echo " 🔴 Чего эта подмена НЕ доказывает: что НАСТОЯЩИЙ сторож, увидев"
|
||||
echo " настоящую поломку уборки, покраснеет. Это дело самих сторожей, и"
|
||||
echo " каждого из них показывали красным в своём круге. Здесь доказывается"
|
||||
echo " только проводка: обход замечает красноту и доносит её до итога."
|
||||
|
||||
VYVOD_PROBY="$(
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=30 -i "$KLYUCH" "$SERVER" 'bash -s' <<'REMOTE' 2>&1
|
||||
set -u
|
||||
KORZINA="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$KORZINA"' EXIT
|
||||
cp /home/ubuntu/lena-golos/sutochnyy-obhod.sh "$KORZINA/" || { echo "ПРОБА: не скопировал обход"; exit 1; }
|
||||
|
||||
sdelat_storozha() { # <имя> <код> <проверок> <красных>
|
||||
cat > "$KORZINA/proverka-$1.sh" <<EOF
|
||||
#!/bin/bash
|
||||
echo "✅ ЦЕЛО: проверок $3, красных $4"
|
||||
exit $2
|
||||
EOF
|
||||
chmod +x "$KORZINA/proverka-$1.sh"
|
||||
}
|
||||
|
||||
# --- случай А: все подставные зелёные
|
||||
sdelat_storozha odin 0 7 0
|
||||
sdelat_storozha dva 0 9 0
|
||||
LENA_OTCHYOT="$KORZINA/itog.json" LENA_ZHURNAL_OBHODA="$KORZINA/zhurnal.log" \
|
||||
LENA_NASTROYKI="$KORZINA/net-takogo" bash "$KORZINA/sutochnyy-obhod.sh" >/dev/null 2>&1
|
||||
echo "А-код=$?"
|
||||
echo "А-итог=$(tr -d ' \n' < "$KORZINA/itog.json" 2>/dev/null)"
|
||||
|
||||
# --- случай Б: один подставной сторож КРАСНЫЙ
|
||||
sdelat_storozha dva 1 9 4
|
||||
LENA_OTCHYOT="$KORZINA/itog.json" LENA_ZHURNAL_OBHODA="$KORZINA/zhurnal.log" \
|
||||
LENA_NASTROYKI="$KORZINA/net-takogo" bash "$KORZINA/sutochnyy-obhod.sh" >/dev/null 2>&1
|
||||
echo "Б-код=$?"
|
||||
echo "Б-итог=$(tr -d ' \n' < "$KORZINA/itog.json" 2>/dev/null)"
|
||||
|
||||
# --- случай В: обход НИЧЕГО не удалил
|
||||
echo "В-осталось=$(ls -1 "$KORZINA"/proverka-*.sh 2>/dev/null | wc -l)"
|
||||
|
||||
# --- случай Г: отправка не настроена — обход не падает и говорит об этом
|
||||
if grep -q 'отправка не настроена' "$KORZINA/zhurnal.log"; then echo "Г=сказал"; else echo "Г=промолчал"; fi
|
||||
REMOTE
|
||||
)"
|
||||
|
||||
printf '%s\n' "$VYVOD_PROBY" | sed 's/^/ | /'
|
||||
|
||||
vzyat() { printf '%s\n' "$VYVOD_PROBY" | sed -n "s/^$1=//p" | head -1; }
|
||||
|
||||
if [ "$(vzyat 'А-код')" = "0" ]; then zelenyy "проба А: обход на зелёных отработал"; else krasnyy "проба А: обход не отработал"; fi
|
||||
|
||||
A_ITOG="$(vzyat 'А-итог')"
|
||||
if printf '%s' "$A_ITOG" | grep -qF '"storozhey_krasnyh":0'; then
|
||||
zelenyy "проба А: в итоге красных 0"
|
||||
else
|
||||
krasnyy "проба А: в итоге красных НЕ 0 — обход выдумал красноту на ровном месте"
|
||||
fi
|
||||
if printf '%s' "$A_ITOG" | grep -qF '"storozhey_vsego":2'; then
|
||||
zelenyy "проба А: обход нашёл обоих подставных сторожей сам, списком файлов"
|
||||
else
|
||||
krasnyy "проба А: обход насчитал не двоих — значит сторожей он ищет не так, как думаем"
|
||||
fi
|
||||
|
||||
B_ITOG="$(vzyat 'Б-итог')"
|
||||
if printf '%s' "$B_ITOG" | grep -qF '"storozhey_krasnyh":1'; then
|
||||
zelenyy "проба Б: обход заметил красного сторожа"
|
||||
else
|
||||
krasnyy "проба Б: обход НЕ заметил красного сторожа — вся затея бесполезна"
|
||||
fi
|
||||
if printf '%s' "$B_ITOG" | grep -qF '"imya":"proverka-dva"' && printf '%s' "$B_ITOG" | grep -qF '"provalov":4'; then
|
||||
zelenyy "проба Б: итог называет ИМЯ покрасневшего и число его провалов"
|
||||
else
|
||||
krasnyy "проба Б: итог не называет, КТО покраснел — по такому письму чинить нечего"
|
||||
fi
|
||||
|
||||
if [ "$(vzyat 'В-осталось')" = "2" ]; then
|
||||
zelenyy "проба В: обход не удалил ни одного файла"
|
||||
else
|
||||
krasnyy "проба В: после обхода файлов стало «$(vzyat 'В-осталось')» вместо 2 — обход что-то удаляет"
|
||||
fi
|
||||
|
||||
if [ "$(vzyat 'Г')" = "сказал" ]; then
|
||||
zelenyy "проба Г: ненастроенная отправка не роняет обход и попадает в журнал"
|
||||
else
|
||||
krasnyy "проба Г: про ненастроенную отправку обход промолчал"
|
||||
fi
|
||||
echo
|
||||
|
||||
# --------------------------------------------- 8. журнал обхода ------------
|
||||
echo "8. Журнал обхода на машине"
|
||||
POSLEDNYAYA="$(na_servere "tail -3 '$PAPKA_TAM/sutochnyy-obhod.log' 2>/dev/null")"
|
||||
if [ -n "$POSLEDNYAYA" ]; then
|
||||
zelenyy "журнал не пуст, последние строки:"
|
||||
printf '%s\n' "$POSLEDNYAYA" | sed 's/^/ | /'
|
||||
else
|
||||
krasnyy "журнал обхода пуст или его нет — обход не ходил ни разу"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "──────────────────────────────────────────────"
|
||||
if [ "$PROVALOV" -eq 0 ]; then
|
||||
echo "✅ ЦЕЛО: проверок $PROVERENO, красных $PROVALOV"
|
||||
exit 0
|
||||
else
|
||||
echo "🔴 БЕДА: проверок $PROVERENO, красных $PROVALOV"
|
||||
exit 1
|
||||
fi
|
||||
@@ -71,7 +71,32 @@ zelenyy() { PROVERENO=$(( PROVERENO + 1 )); echo " ✅ $1"; }
|
||||
krasnyy() { PROVERENO=$(( PROVERENO + 1 )); PROVALOV=$(( PROVALOV + 1 )); echo " 🔴 $1"; }
|
||||
|
||||
# Одна дверь на сервер: и проще читать, и легче подменить при пробе.
|
||||
na_servere() { ssh -o BatchMode=yes -o ConnectTimeout=20 -i "$KLYUCH" "$SERVER" "$1" 2>/dev/null; }
|
||||
#
|
||||
# 🔴 У двери две створки. Обычная — по ssh с рабочего места. Вторая, «местная»
|
||||
# (LENA_MESTNAYA=1), означает «мы УЖЕ на этой машине»: команды идут прямо
|
||||
# здесь, без ssh. Она нужна суточному обходу sutochnyy-obhod.sh, который
|
||||
# машина запускает сама: закрытого ssh-ключа у машины нет и заводить его
|
||||
# нельзя — ключ от самой себя на машине с голосами живых людей это дыра.
|
||||
#
|
||||
# 🔴 Что при местной створке ТЕРЯЕТСЯ: проверка 0 «достучались до машины»
|
||||
# становится бессодержательной, а эталоны берутся из машинной копии папки,
|
||||
# а не из хранилища. Совпадение копии с хранилищем проверяет отдельный
|
||||
# сторож proverka-sutochnogo-obhoda.sh — он ходит с рабочего места по ssh.
|
||||
if [ "${LENA_MESTNAYA:-0}" = "1" ]; then
|
||||
SERVER="сама машина (без ssh)"
|
||||
na_servere() { bash -c "$1" 2>/dev/null; }
|
||||
else
|
||||
na_servere() { ssh -o BatchMode=yes -o ConnectTimeout=20 -i "$KLYUCH" "$SERVER" "$1" 2>/dev/null; }
|
||||
fi
|
||||
|
||||
# Положить файл на сервер. Та же пара створок, что у двери выше.
|
||||
polozhit_na_server() { # <файл здесь> <путь там>
|
||||
if [ "${LENA_MESTNAYA:-0}" = "1" ]; then
|
||||
cp -- "$1" "$2" 2>/dev/null
|
||||
else
|
||||
scp -q -o BatchMode=yes -i "$KLYUCH" "$1" "$SERVER:$2" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# Побайтовая сверка со снятием \r: рабочее дерево на Windows добавляет его сам.
|
||||
sverit_pobaytno() { # <файл здесь> <файл там> <как называется>
|
||||
@@ -124,8 +149,7 @@ else
|
||||
if [ -z "$VREMENNAYA" ]; then
|
||||
krasnyy "не вышло завести временную папку на сервере — проба не состоялась"
|
||||
else
|
||||
if scp -q -o BatchMode=yes -i "$KLYUCH" "$PROBA_ZDES" \
|
||||
"$SERVER:$VREMENNAYA/proba-mosta-bez-zvonka.py" 2>/dev/null; then
|
||||
if polozhit_na_server "$PROBA_ZDES" "$VREMENNAYA/proba-mosta-bez-zvonka.py"; then
|
||||
# Пробу кладём В ПАПКУ ЛЕНЫ ссылкой? Нет: она сама подкладывает свой путь
|
||||
# в поиск ввоза, поэтому запускаем её ИЗ папки Лены, оставив файл во
|
||||
# временной. Мост ввозится по имени most из папки запуска.
|
||||
@@ -424,7 +448,20 @@ echo " 🪤 Образец ищем в скобках — «челове[к]:
|
||||
echo " своя же команда записывается в /var/log/auth.log и в системный журнал."
|
||||
|
||||
NAYDENO="$(na_servere "sudo grep -rlaE 'челове[к]:' /var/log /home /opt /tmp /srv /root 2>/dev/null | sort")"
|
||||
ZHDALI="/home/ubuntu/lena-inworld/__pycache__/most.cpython-310.pyc
|
||||
# 🔴 Два места из списка появились 06.08.2026 вместе с суточным обходом
|
||||
# (З-0.10) и разобраны поимённо, а не заметены:
|
||||
# lena-golos/most.py — ЭТАЛОННАЯ копия моста. Это исходный
|
||||
# текст программы, в нём образец строки, а не чьи-то слова. Ровно по
|
||||
# той же причине в списке давно стоит lena-inworld/most.py.
|
||||
# lena-golos/sutochnyy-obhod.log — журнал обхода. В него ложится вывод
|
||||
# сторожей, а там строки вида «челове[к]: 5 знаков, 1 слов» (образец
|
||||
# в скобках нарочно — иначе этот файл сам стал бы новым местом) — МЕРА, не
|
||||
# речь. Место без срока и без уборки заводить было бы нельзя, поэтому
|
||||
# обход ОБНУЛЯЕТ этот журнал в начале каждого прогона: в нём живёт
|
||||
# ровно один последний обход и вырасти он не может.
|
||||
ZHDALI="/home/ubuntu/lena-golos/most.py
|
||||
/home/ubuntu/lena-golos/sutochnyy-obhod.log
|
||||
/home/ubuntu/lena-inworld/__pycache__/most.cpython-310.pyc
|
||||
/home/ubuntu/lena-inworld/most-vhodyashchie.log
|
||||
/home/ubuntu/lena-inworld/most.log
|
||||
/home/ubuntu/lena-inworld/most.py
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# ============================================================================
|
||||
# РАСПИСАНИЕ СУТОЧНОГО ОБХОДА СТОРОЖЕЙ — эталон для сверки
|
||||
#
|
||||
# Куда кладётся: сервер liderra-render (51.250.1.97), файл
|
||||
# /etc/cron.d/lena-sutochnyy-obhod
|
||||
# Когда: 06.08.2026.
|
||||
#
|
||||
# Зачем расписание вообще. Сторожа на этой машине проверяют, что уборка
|
||||
# записей телефонных разговоров с живыми людьми работает и что слова людей не
|
||||
# текут в журналы. До 06.08.2026 их запускал ТОЛЬКО человек, который про них
|
||||
# помнил, — то есть, по факту, почти никогда. Уборка могла умереть молча и
|
||||
# копить чужие голоса месяцами. Этих строк не будет — всё построенное снова
|
||||
# станет бумагой: обход есть, а звать его некому.
|
||||
#
|
||||
# 🔴 ИМЯ ФАЙЛА НА СЕРВЕРЕ — БЕЗ ТОЧКИ, И ЭТО НЕ ПРИДИРКА. Из папки
|
||||
# /etc/cron.d cron берёт только те файлы, чьё имя состоит из букв, цифр,
|
||||
# подчёркиваний и дефисов. Файл, в имени которого есть точка, он МОЛЧА
|
||||
# пропускает — расписание будет лежать на месте и не сработать никогда, и
|
||||
# ни одной жалобы в журнале не появится. Поэтому в хранилище файл зовётся
|
||||
# sutochnyy-obhod.cron (человеку видно, что это), а на сервере —
|
||||
# lena-sutochnyy-obhod, без всякого расширения.
|
||||
#
|
||||
# 🔴 Текст на сервере и здесь обязан совпадать ПОБАЙТОВО. Это проверяет
|
||||
# сторож proverka-sutochnogo-obhoda.sh с рабочего места.
|
||||
# ============================================================================
|
||||
|
||||
SHELL=/bin/bash
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
# Когда ходим: раз в сутки, 02:20 по времени сервера (сервер живёт по UTC),
|
||||
# это 05:20 по Москве.
|
||||
#
|
||||
# Почему именно так:
|
||||
# - раз в сутки — ровно то, что решил владелец (Р116). Чаще незачем: машина
|
||||
# делит железо с рендером боевого «Поиска клиентов» (решение Р84);
|
||||
# - 02:20 UTC — ПОСЛЕ обеих уборок и с запасом: чистка записей ходит в
|
||||
# 01:40, чистка следа проб в 01:50. Сторожа обязаны смотреть на свежий
|
||||
# результат уборки, а не заставать её в разгаре. В одну минуту с уборками
|
||||
# не встаём нарочно: тогда обход мерил бы недоделанное;
|
||||
# - 05:20 по Москве — робот не звонит, портал не заливает вечернюю порцию,
|
||||
# людей на машине нет;
|
||||
# - минута НЕ круглая и не :17 — в :17 каждый час ходит штатный
|
||||
# run-parts /etc/cron.hourly, и вставать с ним в одну минуту незачем.
|
||||
#
|
||||
# От кого ходим: от ubuntu.
|
||||
# - от него же работают обе Лены и обе уборки;
|
||||
# - ему принадлежит папка /home/ubuntu/lena-golos, куда пишется итог;
|
||||
# - root не берём: полные права здесь не нужны ни для чего, обход НИЧЕГО
|
||||
# не удаляет (решение владельца Р94) — он только читает и считает.
|
||||
#
|
||||
# Про flock: два обхода разом не нужны. -n значит «не дожидаться»: если
|
||||
# предыдущий ещё идёт, новый молча уходит. Пропущенный обход не страшен —
|
||||
# страшен ЗАСТРЯВШИЙ, а портал это увидит по возрасту итога.
|
||||
#
|
||||
# Про хвост команды: обход пишет всё в свой журнал сам, поэтому обычный вывод
|
||||
# в /dev/null; а если заругается сам bash (а не обход), ругань обязана попасть
|
||||
# человеку на глаза, а не сгинуть в несуществующей почте — потому 2>> в журнал.
|
||||
20 2 * * * ubuntu /usr/bin/flock -n /tmp/lena-sutochnyy-obhod.lock /bin/bash /home/ubuntu/lena-golos/sutochnyy-obhod.sh >/dev/null 2>>/home/ubuntu/lena-golos/sutochnyy-obhod.log
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/bin/bash
|
||||
# СУТОЧНЫЙ ОБХОД СТОРОЖЕЙ — эталон для сверки.
|
||||
#
|
||||
# Куда кладётся: сервер liderra-render (51.250.1.97), файл
|
||||
# /home/ubuntu/lena-golos/sutochnyy-obhod.sh
|
||||
# Кто зовёт: /etc/cron.d/lena-sutochnyy-obhod (эталон рядом,
|
||||
# sutochnyy-obhod.cron). Больше НИКТО — руками звать можно,
|
||||
# но жить обход обязан без человека.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# ЗАЧЕМ ЭТОТ ФАЙЛ ВООБЩЕ СУЩЕСТВУЕТ
|
||||
#
|
||||
# На этой машине живут сторожа. Они проверяют, что уборка записей телефонных
|
||||
# разговоров с живыми людьми вправду работает и что слова этих людей не текут
|
||||
# в журналы. До 06.08.2026 их запускал ТОЛЬКО человек, который про них помнил.
|
||||
#
|
||||
# Это и есть беда. Уборка умирает молча: расписание сломается, скрипт исчезнет,
|
||||
# права папки съедут — и голоса чужих людей копятся месяцами, пока кто-нибудь
|
||||
# случайно не запустит сторожа рукой. Это персональные данные и закон.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# 🔴 ГЛАВНАЯ ОПАСНОСТЬ САМОГО ЭТОГО ФАЙЛА: он — новый молчун.
|
||||
#
|
||||
# Умри он тихо — и выглядеть это будет как «всё хорошо»: последний итог лежит
|
||||
# на месте, зелёный, и никто не заметит, что он позавчерашний. Поэтому:
|
||||
#
|
||||
# 1. Внутри итога стоит ЕГО СОБСТВЕННАЯ дата (поле sobrano_v), а не время,
|
||||
# когда портал его получил. Портал считает возраст по ней. Отправь кто-то
|
||||
# вчерашний файл сегодня — портал увидит вчерашнюю дату и заругается.
|
||||
# 2. Итог называет, СКОЛЬКО сторожей он гонял. Появится четвёртый сторож, а
|
||||
# обход по-прежнему будет гонять трёх — портал увидит несовпадение с тем,
|
||||
# сколько он ждёт, и заругается. Сторожа ищутся списком файлов, а не
|
||||
# перечислены здесь руками: положили новый — обход его подхватит сам.
|
||||
# 3. Зелёный итог недельной давности хуже красного сегодняшнего. Возраст
|
||||
# портал проверяет ВСЕГДА, даже когда внутри всё зелено.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# ЧЕГО ЭТОТ ФАЙЛ НЕ ДЕЛАЕТ И НЕ БУДЕТ
|
||||
#
|
||||
# - НИЧЕГО не удаляет (решение владельца Р94). Он только читает и считает.
|
||||
# - Не перезапускает служб, не трогает Asterisk, не звонит.
|
||||
# - Не кладёт в итог ни одной строки вывода сторожей — только имя сторожа,
|
||||
# код и два числа. Причина не в краткости: вывод сторожей содержит куски
|
||||
# журналов моста, а там могут быть слова живых людей. Портал этих слов
|
||||
# видеть не должен. Человеку весь вывод лежит рядом, в своём журнале.
|
||||
# - Не знает тайного слова наизусть: слово читается из отдельного файла с
|
||||
# правами 600, которого в хранилище нет и не будет.
|
||||
#
|
||||
# Запуск рукой (то же самое, что делает расписание):
|
||||
# bash /home/ubuntu/lena-golos/sutochnyy-obhod.sh
|
||||
#
|
||||
# Возвращает 0, если обход СОСТОЯЛСЯ (даже если сторожа покраснели), и 1, если
|
||||
# состояться не смог: не нашёл сторожей или не сумел записать итог. Красные
|
||||
# сторожа — не беда обхода, это его работа; беда — молчание.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
set -u
|
||||
|
||||
ZDES="$(cd "$(dirname "$0")" && pwd)"
|
||||
OTCHYOT="${LENA_OTCHYOT:-$ZDES/otchyot-storozhey.json}"
|
||||
ZHURNAL="${LENA_ZHURNAL_OBHODA:-$ZDES/sutochnyy-obhod.log}"
|
||||
NASTROYKI="${LENA_NASTROYKI:-$ZDES/nastroyki-obhoda}"
|
||||
|
||||
NACHALO_SEK="$(date -u +%s)"
|
||||
SOBRANO_V="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
MASHINA="$(hostname)"
|
||||
|
||||
skazat() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >> "$ZHURNAL"; }
|
||||
|
||||
# 🔴 Журнал ОБНУЛЯЕТСЯ в начале каждого обхода, а не копится. Причина не в
|
||||
# аккуратности, а ровно в той беде, ради которой всё это построено.
|
||||
# В журнал ложится полный вывод сторожей, а среди него — строки вида
|
||||
# «👤 челове[к]: 5 знаков, 1 слов» (образец нарочно в скобках: иначе этот
|
||||
# самый файл попадёт в поиск чужого сторожа как новое место с речью, и
|
||||
# сторож будет прав). Сегодня это МЕРА, а не слова: мост
|
||||
# нарочно переписан так, чтобы дословной речи не писать. Но место, куда
|
||||
# что-то похожее на речь ложится БЕЗ СРОКА И БЕЗ УБОРКИ, заводить нельзя —
|
||||
# именно такие места мы здесь и вылавливаем. Обнуление даёт потолок: в
|
||||
# журнале живёт ровно один, последний обход, и вырасти он не может.
|
||||
# Долгая память — не здесь: она в итоге (файл + отметка в портале).
|
||||
: > "$ZHURNAL"
|
||||
|
||||
skazat "── обход начат ──"
|
||||
|
||||
# ------------------------------------------------- 1. кого гоняем ----------
|
||||
#
|
||||
# Сторожа ищутся списком файлов, а не перечислены здесь. Положили в папку
|
||||
# нового proverka-*.sh — он попадёт в обход сам, и число сторожей в итоге
|
||||
# вырастет. Портал сверит это число с тем, сколько ждёт, и заругается, пока
|
||||
# человек не подтвердит прибавление. Так «сторожей стало четыре, а стучит
|
||||
# по-прежнему три» перестаёт быть возможным молча.
|
||||
STOROZHA=()
|
||||
for F in "$ZDES"/proverka-*.sh; do
|
||||
[ -f "$F" ] || continue
|
||||
STOROZHA+=("$F")
|
||||
done
|
||||
|
||||
if [ "${#STOROZHA[@]}" -eq 0 ]; then
|
||||
skazat "🔴 сторожей не нашлось вовсе в $ZDES — обход не состоялся"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
skazat "нашёл сторожей: ${#STOROZHA[@]}"
|
||||
|
||||
# ------------------------------------------------- 2. обход ----------------
|
||||
#
|
||||
# nice — машина делит железо с рендером боевого «Поиска клиентов» (решение
|
||||
# владельца Р84). Обход обязан уступать дорогу всему остальному.
|
||||
KRASNYH=0
|
||||
ZAPISI=""
|
||||
|
||||
for F in "${STOROZHA[@]}"; do
|
||||
IMYA="$(basename "$F" .sh)"
|
||||
VYVOD="$(LENA_MESTNAYA=1 nice -n 15 bash "$F" 2>&1)"
|
||||
KOD=$?
|
||||
|
||||
# Хвостовая строка сторожа: «проверок 26, красных 0». Не разобрали — ставим
|
||||
# -1: портал прочтёт это как «непонятно» и заругается, а не промолчит.
|
||||
CHISLA="$(printf '%s\n' "$VYVOD" | sed -n 's/.*проверок \([0-9][0-9]*\), красных \([0-9][0-9]*\).*/\1 \2/p' | tail -1)"
|
||||
PROVEROK="$(printf '%s' "$CHISLA" | cut -d' ' -f1)"
|
||||
PROVALOV="$(printf '%s' "$CHISLA" | cut -d' ' -f2)"
|
||||
[ -n "${PROVEROK:-}" ] || PROVEROK=-1
|
||||
[ -n "${PROVALOV:-}" ] || PROVALOV=-1
|
||||
|
||||
if [ "$KOD" != "0" ] || [ "$PROVALOV" != "0" ]; then
|
||||
KRASNYH=$(( KRASNYH + 1 ))
|
||||
fi
|
||||
|
||||
# Весь вывод — человеку в журнал. В итог для портала он НЕ идёт: там бывают
|
||||
# куски журналов моста, а в них слова живых людей.
|
||||
{
|
||||
printf '%s ── сторож %s: код %s, проверок %s, красных %s\n' \
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$IMYA" "$KOD" "$PROVEROK" "$PROVALOV"
|
||||
printf '%s\n' "$VYVOD" | sed 's/^/ | /'
|
||||
} >> "$ZHURNAL"
|
||||
|
||||
[ -n "$ZAPISI" ] && ZAPISI="$ZAPISI,"
|
||||
ZAPISI="$ZAPISI
|
||||
{\"imya\": \"$IMYA\", \"kod\": $KOD, \"proverok\": $PROVEROK, \"provalov\": $PROVALOV}"
|
||||
done
|
||||
|
||||
# ------------------------------------------------- 3. слепок эталонов ------
|
||||
#
|
||||
# Одно число на всю папку: если хоть один эталон на машине разойдётся с
|
||||
# хранилищем, слепок изменится. Сам портал сверить его не с чем — сравнивает
|
||||
# человек и сторож proverka-sutochnogo-obhoda.sh с рабочего места. Но в итоге
|
||||
# слепок лежит, и «когда именно разошлось» становится видно по истории.
|
||||
SLEPOK="$(
|
||||
cd "$ZDES" || exit 0
|
||||
for F in *; do
|
||||
[ -f "$F" ] || continue
|
||||
case "$F" in otchyot-*|nastroyki-*|*.log|*.otvet) continue ;; esac
|
||||
sha256sum "$F"
|
||||
done | LC_ALL=C sort | sha256sum | cut -d' ' -f1
|
||||
)"
|
||||
|
||||
DLILOS_MS=$(( ( $(date -u +%s) - NACHALO_SEK ) * 1000 ))
|
||||
|
||||
# ------------------------------------------------- 4. итог файлом ----------
|
||||
VREMENNYY="$OTCHYOT.novyy"
|
||||
cat > "$VREMENNYY" <<JSON
|
||||
{
|
||||
"shema": 1,
|
||||
"sobrano_v": "$SOBRANO_V",
|
||||
"mashina": "$MASHINA",
|
||||
"dlilos_ms": $DLILOS_MS,
|
||||
"storozhey_vsego": ${#STOROZHA[@]},
|
||||
"storozhey_krasnyh": $KRASNYH,
|
||||
"slepok_etalonov": "$SLEPOK",
|
||||
"storozha": [$ZAPISI
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
if ! mv -f "$VREMENNYY" "$OTCHYOT"; then
|
||||
skazat "🔴 не смог записать итог в $OTCHYOT — обход не состоялся"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
skazat "итог записан: сторожей ${#STOROZHA[@]}, красных $KRASNYH, слепок ${SLEPOK:0:12}"
|
||||
|
||||
# ------------------------------------------------- 5. стук в портал --------
|
||||
#
|
||||
# 🔴 Портал сам на эту машину НЕ ходит и ходить не будет: у него нет и не
|
||||
# должно быть ключа от машины, где лежат голоса живых людей (решение
|
||||
# владельца Р116). Стучится машина.
|
||||
#
|
||||
# Дорога та же, какой уже пользуется поставщик: подпись тела на тайном слове.
|
||||
# Слово в адрес НЕ кладём — адреса текут в журналы nginx целиком, а тело с
|
||||
# подписью не течёт никуда. Слово лежит в отдельном файле с правами 600,
|
||||
# рядом с этим скриптом, и в хранилище его нет.
|
||||
if [ ! -f "$NASTROYKI" ]; then
|
||||
skazat "отправка не настроена: нет $NASTROYKI. Итог лежит файлом, портал его не получит."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
PORTAL_URL=""
|
||||
TAYNOE_SLOVO=""
|
||||
. "$NASTROYKI"
|
||||
|
||||
if [ -z "$PORTAL_URL" ] || [ -z "$TAYNOE_SLOVO" ]; then
|
||||
skazat "отправка не настроена: в $NASTROYKI пусто PORTAL_URL или TAYNOE_SLOVO."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PODPIS="$(openssl dgst -sha256 -hmac "$TAYNOE_SLOVO" -r < "$OTCHYOT" | cut -d' ' -f1)"
|
||||
|
||||
KOD_OTVETA="$(
|
||||
curl -sS -m 30 -o "$ZHURNAL.otvet" -w '%{http_code}' \
|
||||
-X POST "$PORTAL_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "X-Storozh-Podpis: sha256=$PODPIS" \
|
||||
--data-binary "@$OTCHYOT" 2>>"$ZHURNAL"
|
||||
)"
|
||||
|
||||
if [ "$KOD_OTVETA" = "200" ] || [ "$KOD_OTVETA" = "202" ]; then
|
||||
skazat "портал принял итог, ответ $KOD_OTVETA"
|
||||
else
|
||||
# 🔴 Не роняем обход: итог уже лежит файлом, и это главное. Но и не молчим —
|
||||
# строка в журнале обязана быть, иначе непринятый стук невидим.
|
||||
skazat "🔴 портал НЕ принял итог, ответ ${KOD_OTVETA:-нет ответа}: $(head -c 300 "$ZHURNAL.otvet" 2>/dev/null)"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -2998,6 +2998,56 @@ ykob
|
||||
Yosuke
|
||||
Zyy
|
||||
|
||||
# Слова переделки лендинга 06.08.2026 — разбор эталона, промт субагенту,
|
||||
# приёмка, контракт с Fable, ответы GPT-5.6 Sol и Perplexity.
|
||||
# Дословная цитата владельца в протоколе гриллинга от 06.08.2026.
|
||||
# Опечатка его, но цитата приведена слово в слово и правке не подлежит.
|
||||
сдельть
|
||||
|
||||
# Сборка docs/lending-sborka/ 06.08.2026 — имена файлов латиницей,
|
||||
# названия моделей gen-api и расширения форматов трёхмерных моделей.
|
||||
baza
|
||||
blizko
|
||||
cepochka
|
||||
chto
|
||||
dokazannoe
|
||||
fbx
|
||||
hrom
|
||||
iclight
|
||||
imagen
|
||||
izolyaciya
|
||||
kadr
|
||||
kadram
|
||||
kartinka
|
||||
kling
|
||||
komnata
|
||||
lending
|
||||
ltx
|
||||
modeli
|
||||
nastoyashchie
|
||||
nazvaniya
|
||||
novoy
|
||||
OES
|
||||
prodayom
|
||||
produkt
|
||||
promtov
|
||||
razbor
|
||||
resursov
|
||||
resursy
|
||||
sborka
|
||||
sessii
|
||||
skily
|
||||
stl
|
||||
subagentam
|
||||
tehnicheskaya
|
||||
tripo
|
||||
usdz
|
||||
veo
|
||||
vkus
|
||||
vladelca
|
||||
vkusa
|
||||
vneshnie
|
||||
zagolovok
|
||||
Бенто
|
||||
мудборд
|
||||
запускальщик
|
||||
Снимальщик
|
||||
|
||||
|
||||
@@ -3694,6 +3694,272 @@ Managed PG стоит `anon 1.3.2`, статическое маскирован
|
||||
|
||||
---
|
||||
|
||||
### Р122. Машина стучится в портал ПОДПИСЬЮ ПИСЬМА, а не тайным словом в адресе
|
||||
|
||||
**Вопрос.** Решение **Р116** от 06.08.2026 назвало путь: машина шлёт итог
|
||||
сторожей «тайным словом в адресе плюс список разрешённых машин», как уже
|
||||
устроен приём лидов от поставщика. Помощник, строивший задачу, **исполнил это
|
||||
иначе** и сказал об этом вслух. Владельцу вопрос вынесен 07.08.2026 с полным
|
||||
набором дорог.
|
||||
|
||||
**Замер, из-за которого возник вопрос.** Адрес страницы попадает в журналы
|
||||
веб-сервера **целиком**. Тайное слово, стоящее в адресе, оттуда утекает всякому,
|
||||
кто эти журналы читает. Портал это уже признал раньше нас: рядом с адресным
|
||||
входом поставщика живёт второй, **бессекретный** — подпись тела, заведённая
|
||||
именно для того, чтобы секрет не светился в адресе.
|
||||
|
||||
**Решение владельца: оставить подпись письма.**
|
||||
|
||||
**Довод:** тайное слово по сети не передаётся **вообще ни разу** — машина им
|
||||
лишь запечатывает письмо. В журналы попасть нечему, а содержимое письма при
|
||||
этом нельзя подменить. Список разрешённых машин остаётся вторым слоем.
|
||||
|
||||
**Отрезано и почему:**
|
||||
|
||||
- ✂️ **вернуть слово в адресе, строго по Р116** — просто и одинаково со старым
|
||||
входом поставщика, но платится утечкой в журналы и сменой слова при каждой
|
||||
утечке журнала. Старый вход поставщика остался таким **от прежнего уговора**,
|
||||
а не потому, что так лучше.
|
||||
- ✂️ **и то и другое сразу** — наследует утечку от первого слоя, а защиты сверх
|
||||
подписи не даёт почти никакой.
|
||||
- ✂️ **пропуск в отдельной строке письма**, как у наших ключей доступа — слово
|
||||
в журналы не попадает, но содержимое письма не запечатано, и подменить его
|
||||
теоретически можно.
|
||||
|
||||
🔴 **Чего это НЕ закрывает:**
|
||||
|
||||
- **Подделку свежего зелёного итога тем, кто узнал слово.** Односторонний отчёт
|
||||
в принципе не отличает машину от того, кто знает слово. Смягчение одно —
|
||||
список разрешённых машин: подделку придётся слать с самой машины робота.
|
||||
- **Порядок смены слова** по-прежнему не назначен — это стояло открытым от Р116
|
||||
и стоит до сих пор.
|
||||
|
||||
---
|
||||
|
||||
### Р123. К роботу ходим только по шифрованной дороге, кроме своей машины
|
||||
|
||||
**Вопрос.** Портал передаёт роботу тайный ключ. Замерено 07.08.2026 своим
|
||||
прибором: адрес робота можно записать **без шифрования**, и тогда ключ уезжает
|
||||
по сети открытым текстом. Помощник нашёл это сам и **отказался решать за
|
||||
владельца**, потому что запрет сломал бы проверку работы на своём компьютере.
|
||||
|
||||
**Разведка перед вопросом** (правило «спроси у самого предмета»): я сходил на
|
||||
машину робота и замерил — **шифрованный вход у неё уже слушает**. То есть живому
|
||||
роботу отказ от шифрования сегодня не нужен ни для чего.
|
||||
|
||||
**Решение владельца: требовать шифрование, кроме своей машины.**
|
||||
|
||||
**Довод:** адрес без шифрования портал просто не примет — скажет «дверь закрыта»
|
||||
и звонить не будет. Живой робот не пострадает: шифрование у его машины есть.
|
||||
Исключение ровно одно и по устройству — разработчик на своём компьютере.
|
||||
|
||||
**Отрезано и почему:**
|
||||
|
||||
- ✂️ **требовать всегда, без исключений** — строже, но проверка работы на своём
|
||||
компьютере становится заметно неудобнее, и каждый раз придётся возиться с
|
||||
самодельными сертификатами. Цена не окупается: своя машина в сеть не выходит.
|
||||
- ✂️ **оставить как есть, записать в бумаги** — бумага не защищает. Одна
|
||||
опечатка в настройке, и ключ утекает **молча**, без единого признака.
|
||||
- ✂️ **задать полный адрес робота заранее и сверять целиком** — самое жёсткое,
|
||||
но любой переезд робота потребует правки настроек, а робот у нас пока
|
||||
переезжает.
|
||||
|
||||
🔴 **Чего это НЕ закрывает:**
|
||||
|
||||
- **Кто именно на том конце.** Шифрование бережёт ключ в пути, но не проверяет,
|
||||
что отвечает наш робот, а не подменённая машина по тому же адресу.
|
||||
- **Исключение для своей машины — само по себе дверь.** Ошибись кто-нибудь
|
||||
настройкой на боевом так, чтобы портал счёл себя «своей машиной», — запрет
|
||||
отвалится. Сторожа на это нет.
|
||||
|
||||
---
|
||||
|
||||
### Р124. Робот помнит опознавательные знаки заданий СУТКИ
|
||||
|
||||
**Вопрос.** У каждого задания на звонок есть опознавательный знак — по нему
|
||||
робот узнаёт повтор и не звонит человеку второй раз, если ответ до портала не
|
||||
дошёл. Замерено 07.08.2026 своим прибором: знак складывается из клиента,
|
||||
кампании, номера и числа попытки, и **времени в нём нет**. Значит при повторном
|
||||
запуске той же кампании через месяц у тех же номеров получится **тот же знак** —
|
||||
и робот может отказаться звонить, сочтя законный звонок повтором. Срок хранения
|
||||
знаков нигде не был назначен.
|
||||
|
||||
**Решение владельца: робот помнит знаки сутки.**
|
||||
|
||||
**Довод:** от двойного звонка это бережёт полностью — обрыв связи и повторная
|
||||
попытка случаются за минуты, не за месяцы. А повторный запуск кампании спустя
|
||||
сутки проходит свободно.
|
||||
|
||||
**Отрезано и почему:**
|
||||
|
||||
- ✂️ **вписать в знак номер запуска кампании** — точнее всего, но требует
|
||||
правки уже построенного и упирается в то, ведём ли мы вообще учёт запусков.
|
||||
- ✂️ **помнить вечно** — надёжнее всего против двойного звонка, но повторный
|
||||
запуск кампании по тем же номерам не состоится вовсе, **и человек не поймёт
|
||||
почему**: отказ будет выглядеть как исправная работа.
|
||||
- ✂️ **отложить до задачи про робота** — вопрос забылся бы и всплыл на живых
|
||||
людях.
|
||||
|
||||
🔴 **Чего это НЕ закрывает:**
|
||||
|
||||
- **Как портал считает число попытки.** Знак различает попытки по этому числу;
|
||||
если портал успел записать неудавшийся звонок и на повторе прибавит попытку —
|
||||
знак станет другим, и защита не сработает **именно в том случае, ради
|
||||
которого заведена**. Правило счёта попыток назначается в задаче про робота.
|
||||
- **Переживают ли знаки перезапуск робота.** Забудь он их при перезапуске —
|
||||
двойной звонок снова возможен. Не обсуждали.
|
||||
|
||||
---
|
||||
|
||||
### Р125. Номер, у которого общего окна нет вовсе, НЕ набираем — и говорим клиенту до запуска
|
||||
|
||||
**Вопрос.** Р115 велело считать рамку звонка **пересечением двух окон**: часы
|
||||
получателя по его месту и часы менеджера в часах клиента. Построив это
|
||||
07.08.2026, я замерил своим прибором живой случай: **менеджер в Москве работает
|
||||
14:00–18:00, человек во Владивостоке — общих часов нет ни одного.** И не будет
|
||||
никогда, пока не поменять часы: оба окна суточные и в постоянных поясах.
|
||||
|
||||
Правило отвечает на такой случай честной пустотой. Что делать дальше — не решал
|
||||
никто.
|
||||
|
||||
**Решение владельца: не звонить и сказать клиенту ДО запуска.**
|
||||
|
||||
**Довод:** такие номера считаются заранее, и человек видит своими глазами: «из
|
||||
20 000 номеров 3 400 не будут набраны никогда — часы вашего менеджера не
|
||||
сходятся с их часами». Дальше он сам решает, расширять ли часы. Молчаливый
|
||||
пропуск части списка — то же, что обман.
|
||||
|
||||
**Отрезано и почему:**
|
||||
|
||||
- ✂️ **звонить в общую рамку, часы менеджера игнорировать** — звонок состоится,
|
||||
но человек, захотевший говорить с живым менеджером, упрётся в спящего. Цена
|
||||
ложится на **живого человека на том конце**, а он ни в чём не виноват.
|
||||
- ✂️ **звонить, но без перевода на менеджера** — робот спрашивает, когда
|
||||
перезвонить. Это уже другая работа, и строить её надо отдельно; вводить её
|
||||
молчком внутри правила часов нельзя.
|
||||
- ✂️ **отложить до экранов** — правило уже умеет отвечать честно, решение нужно
|
||||
только экрану. Но вопрос забылся бы и всплыл на живом запуске, когда часть
|
||||
списка молча не наберётся.
|
||||
|
||||
🔴 **Чего это НЕ закрывает:**
|
||||
|
||||
- **Часам менеджера сегодня негде храниться** — ни таблицы, ни колонки. Пока
|
||||
клиент не может задать их вовсе, и решение работает вхолостую.
|
||||
- **Считать «сколько номеров не наберётся» пока некому** — это работа экрана
|
||||
запуска, и её отдельно никто не заводил.
|
||||
- **Что делать, если клиент часы не расширит.** Номера так и останутся
|
||||
ненабранными, а деньги за них не списывались — но и лида не будет. Не
|
||||
обсуждали.
|
||||
|
||||
---
|
||||
|
||||
### Р126. Дверь боевого «Поиска клиентов» чинится отдельным заходом
|
||||
|
||||
**Вопрос.** Строя защиту дороги к голосовому роботу (Р123), помощник нашёл
|
||||
**то же самое в живом боевом модуле** и чинить не стал — правильно, файл не его.
|
||||
Я перепроверил своим прибором.
|
||||
|
||||
**Замер.** Модуль «Поиск клиентов» шлёт свой тайный ключ на виртуалку-рендер
|
||||
заголовком. Вся его дверь — **две проверки: адрес не пустой и ключ не пустой.**
|
||||
Во всём файле **ноль** упоминаний разбора адреса, списка разрешённых машин и
|
||||
шифрования.
|
||||
|
||||
Отсюда две беды, обе молчаливые: опечатка в настройке уводит ключ **чужому**
|
||||
навсегда и без единого признака; адрес без шифрования отправляет ключ **открытым
|
||||
текстом**. Это единственная точка отказа поиска — 03.08.2026 он уже вставал на
|
||||
день, когда умер соседний сервис.
|
||||
|
||||
**Решение владельца: починить отдельным заходом сейчас**, ровно по тому образцу,
|
||||
что построен для двери к роботу и проверен ножом надзирателя на восьми формах
|
||||
адреса.
|
||||
|
||||
**Отрезано и почему:**
|
||||
|
||||
- ✂️ **записать и отложить** — дыра осталась бы открытой на бою, а закрыл бы её
|
||||
тот, кто следующим откроет бумаги. Если откроет.
|
||||
- ✂️ **сперва сходить на боевой и посмотреть, есть ли беда вправду** — это
|
||||
доступ к боевому, а он только с разрешения владельца; и главное, ответ «сейчас
|
||||
там всё правильно» дыру не закрывает, он закрывает **сегодняшний день**.
|
||||
- ✂️ **не трогать вовсе** — чужой модуль работает, и правка рискует уронить
|
||||
поиск. Риск настоящий, поэтому заход отдельный, со своей приёмкой, и **выкат
|
||||
отдельно от обзвона и со словом владельца**.
|
||||
|
||||
🔴 **Чего это НЕ закрывает:**
|
||||
|
||||
- **Шифрование не проверяет, ЧЕЙ сервис отвечает** — только что ключ не уехал
|
||||
открытым и не по тому адресу.
|
||||
- **Выкат.** Правка живого модуля доезжает до боя своим порядком. Пока не
|
||||
выкачена, на бою всё по-старому.
|
||||
- **Второе такое же место может быть и третьим.** Искали не поиском по всему
|
||||
хранилищу, а наткнулись попутно.
|
||||
|
||||
---
|
||||
|
||||
### Р127. Восьмёрку приводим к семёрке ТОЛЬКО у одиннадцатизначных номеров
|
||||
|
||||
**Вопрос.** Опознавательный знак задания на звонок складывается из клиента,
|
||||
кампании, номера и числа попытки. Номер приводится к одному виду — из него
|
||||
выбрасываются все не-цифры.
|
||||
|
||||
**Замер моим прибором 07.08.2026** — мерил не текст, а то, что вправду уезжает
|
||||
роботу:
|
||||
|
||||
```text
|
||||
'8 999 000-00-01' -> знак z77b56795b28… тело 89990000001
|
||||
'+7 999 000-00-01' -> знак z95d89fef97b… тело 79990000001
|
||||
```
|
||||
|
||||
🔴 **Один и тот же живой человек, просто записанный в списке двумя обычными
|
||||
способами, считается ДВУМЯ разными — и ему позвонят ДВАЖДЫ.** Клиент заплатит
|
||||
за два звонка вместо одного.
|
||||
|
||||
🔑 **Помощник нашёл это сам, чинить не стал и вынес владельцу** — и довод его
|
||||
верен: **ошибка в обратную сторону тише и хуже**. Слей мы двух **разных** людей
|
||||
в один знак — второму не позвонят **никогда и молча**.
|
||||
|
||||
**Решение владельца: приводить `8` к `+7` только для одиннадцати цифр.**
|
||||
|
||||
**Довод:** ровно одиннадцать цифр, начинающихся на `8`, — это российский
|
||||
мобильный, и второго толкования у него нет. Короткие и длинные номера не
|
||||
трогаем вовсе, поэтому **слияние двух разных людей при таком правиле
|
||||
невозможно**.
|
||||
|
||||
**Отрезано и почему:**
|
||||
|
||||
- ✂️ **оставить как есть** — два звонка одному человеку и двойная плата.
|
||||
- ✂️ **чистить номера на входе, при загрузке списка** — правильнее по существу
|
||||
и человек видел бы, сколько дублей убралось, но это **другая работа и другая
|
||||
задача**, которой в плане нет.
|
||||
- ✂️ **отложить до первого живого запуска** — первый запуск и есть тот случай,
|
||||
где живому человеку позвонят дважды.
|
||||
|
||||
🔴 **Чего это НЕ закрывает — замерено после принятия решения:**
|
||||
|
||||
- 🔴 **Пара «10 цифр / 11 цифр» по-прежнему даёт ДВА знака на одного человека.**
|
||||
Замерено прибором надзирателя: `9990000001` и `79990000001` — знаки разные,
|
||||
и **позвонят дважды**. При этом общий нормализатор портала и проверка запрета
|
||||
звонить считают эту пару **одним человеком**. То есть беда, ради которой
|
||||
принято Р127, на этой паре **жива**. Лечение шире Р127: надо сводить **три
|
||||
разных канона телефона**, живущих в обзвоне, а один из них сторожит **запрет
|
||||
звонить**. Отдельное решение владельца.
|
||||
- 🔴 **Цена, которую надзиратель назвал владельцу НЕВЕРНО.** В вопросе было
|
||||
сказано: «у одиннадцати цифр с восьмёркой второго толкования нет». **Есть.**
|
||||
Иностранные номера длиной ровно одиннадцать цифр с кодом на `8` — Вьетнам,
|
||||
Гонконг, Япония, Корея. Большинство дают несуществующий российский номер, но
|
||||
вьетнамский `8496…` превращается в `7496…`, **а 496 — живой код Московской
|
||||
области**: звонок состоится, но **не тому человеку**.
|
||||
🔑 **Смягчение, замеренное тем же помощником:** общий нормализатор портала
|
||||
делает **ровно то же самое** и живёт в двадцати файлах, включая сам обзвон. ⇒
|
||||
Вьетнамский номер портится на входе **везде уже сегодня**, и Р127 опасности
|
||||
**не добавляет** — он прекращает расхождение обзвона с остальным порталом.
|
||||
- **Мусорная строка с одной цифрой** проходит в задание, хотя общий нормализатор
|
||||
портала её отбивает.
|
||||
- **Номер, сменивший владельца.** Знак не различает людей, он различает номера.
|
||||
- **Чистку списка на входе** — она осталась неназначенной работой, и пункты выше
|
||||
закрылись бы именно там.
|
||||
|
||||
---
|
||||
|
||||
## Осталось открытым
|
||||
|
||||
**Открылось при обходе 06.08.2026 — порождено решениями Р105–Р114:**
|
||||
@@ -3707,6 +3973,23 @@ Managed PG стоит `anon 1.3.2`, статическое маскирован
|
||||
идёт письмо** (Р111). Ключа от машины у портала нет.
|
||||
✅ **ЗАКРЫТО владельцем 06.08.2026 — Р116 и Р117.** Машина сама стучится в
|
||||
портал тайным словом; письмо идёт на `ops@liderra.ru` через настройку.
|
||||
🔄 **Уточнено владельцем 07.08.2026 — Р122:** слово в адрес **не кладём**,
|
||||
машина запечатывает письмо подписью. Причина — адреса целиком текут в журналы
|
||||
веб-сервера.
|
||||
|
||||
**Открылось при стройке 07.08.2026:**
|
||||
|
||||
- **Порядок смены тайного слова** (Р116, уточнено Р122) — не назначен.
|
||||
- **Как портал считает число попытки дозвона** (Р124). Знак задания различает
|
||||
попытки по этому числу; неверный счёт отменяет всю защиту от двойного звонка.
|
||||
- **Переживают ли опознавательные знаки перезапуск робота** (Р124).
|
||||
- 🔴 **Предел одновременных заданий не бережёт машину робота так, как обещает
|
||||
Р84.** Замерено 07.08.2026: предел считает **обращения**, а место
|
||||
освобождается сразу после «принял» — тогда как разговор живёт минуты и грузит
|
||||
машину именно он. Портал не может считать живые разговоры сам, пока робот их
|
||||
не сообщит.
|
||||
- **Исключение «своя машина» в Р123 само по себе дверь** — сторожа на случай,
|
||||
когда боевой портал сочтёт себя своей машиной, нет.
|
||||
- **Где кончается «хоть секунда разговора»** (Р112). Ноль секунд и одна секунда
|
||||
различаются, а 0,4 секунды — вопрос.
|
||||
✅ **ЗАКРЫТО владельцем 06.08.2026 — Р120.** Разговор от **одной** секунды.
|
||||
|
||||
@@ -1317,13 +1317,73 @@ Perplexity предлагала запретить в разметке шриф
|
||||
|
||||
- 🔴 **Какой из шести вариантов берём** — лист показан владельцу 06.08, ответа нет.
|
||||
- 🔴 **Какой из трёх прощающих видов берём** — ЗАКРЫТО решением Р-К4 (берём все три).
|
||||
- 🔴 **Сколько денег вкладываем — развилка задана владельцу 06.08, ответа нет.**
|
||||
От неё зависит всё остальное; пути и цены — в таблице выше.
|
||||
- ✅ **Вшивать ли картинки — ЗАКРЫТО решением Р-К6 06.08: нет, никаких.** И картинка-
|
||||
окружение, и снимки портала отклонены владельцем. Следствие принято: хрома и стекла
|
||||
в работе не будет, мир матовый и светящийся.
|
||||
- ✅ **Сколько денег вкладываем — ЗАКРЫТО решением Р-К7 06.08 вечером:** «используя все
|
||||
ресурсы, в том числе любые модели». Счёт gen-api владельца, баланс 2000,3 ₽.
|
||||
- ⚠️ **Вшивать ли картинки — Р-К6 06.08 («нет, никаких») ОТМЕНЕНО решением Р-К7 того же
|
||||
дня.** Смотреть Р-К7 ниже: картинки, карты окружения, трёхмерные модели и хром со
|
||||
стеклом разрешены.
|
||||
- **Расхождение лендинг ↔ кабинет** по ИИ колцентру и VK Рекламе: лендинг обещает без
|
||||
оговорок, кабинет показывает «В разработке, 01.09.2026». Владелец отложил ветку
|
||||
(«делаем лендинг сейчас») — вернуться после лендинга.
|
||||
- **Отсутствие политики обработки ПДн на лендинге** при работающем Вебвизоре — риск
|
||||
принят владельцем осознанно, но не устранён.
|
||||
|
||||
---
|
||||
|
||||
## Р-К7 — «максимально к эталону, любые ресурсы и модели» (06.08, вечер)
|
||||
|
||||
**Слова владельца дословно:**
|
||||
|
||||
> «возвращаемся к нашей задачи video-06-08-26-10-41 просмотри и нам надо сдельть
|
||||
> так же! используя все ресурсы! в том числе любые модели!»
|
||||
|
||||
и раньше в тот же вечер:
|
||||
|
||||
> «так максимально к эталону делаем!»
|
||||
|
||||
**Что это закрывает:**
|
||||
|
||||
- **Р-К6 отменено.** Запрет «картинок в сборке нет вообще» снят. Разрешены файлы
|
||||
картинок, карты окружения, трёхмерные модели и видео внутри панелей.
|
||||
- **Хром, стекло и перелив разрешены обратно.** Следствие Р-К6 («мир матовый») снято.
|
||||
- **Деньги.** «Используя все ресурсы, в том числе любые модели» — это добро на трату
|
||||
со счёта gen-api владельца (баланс 2000,3 ₽ на 06.08 19:20).
|
||||
- **Потолки веса** из спецификации приёмки (1,5 МБ первая загрузка, 3 МБ всё) сняты —
|
||||
они писались под запрет картинок и «максимально к эталону» физически не позволяют.
|
||||
|
||||
**Что НЕ отменено:**
|
||||
|
||||
- **Решение 18 остаётся в силе: кусков нашего портала внутрь панелей нет.** Это
|
||||
отдельное и раньше данное указание, к запрету картинок отношения не имеет.
|
||||
Содержание панелей теперь **рисуется моделями**, а не снимается с экрана портала.
|
||||
- Показ только по `liderra.ru/new`, главную не трогать.
|
||||
- Полный трёхмерный полёт на телефоне обязателен.
|
||||
|
||||
## Разбор эталона по кадрам — что там на самом деле построено
|
||||
|
||||
Смотрено 06.08 вечером: 32 кадра на весь ролик плюс шесть полноразмерных кадров
|
||||
ключевых мест. Ролик 19,16 с, устройство — **четыре разных типа сцены**, а не один.
|
||||
|
||||
| Тип | Секунды | Из чего сделано |
|
||||
|---|---|---|
|
||||
| **Пустота с частицами** | 0,9–2,7 и 17,1–18,9 | Редкие светящиеся точки, тонкое кольцо-линза, длинные шлейфы. Почти чёрный фон. Ролик замыкается в кольцо — конец равен началу |
|
||||
| **Заголовок с хромовым кольцом** | 3,3–4,5 | Объёмные вытянутые буквы «CREATIVE DIGITAL EXPERIENCES» — **бело-серые, матовые, с видимой боковой гранью**, НЕ зеркальные. Рядом одно **зеркальное хромовое кольцо** с сиренево-синим переливом и внутри него стеклянный знак с сильным преломлением. Справа мелкий моноширинный текст в шесть строк, низкая яркость |
|
||||
| **Галерея панелей** | 5,1–11,1 (самая длинная) | Крупные полупрозрачные стеклянные плиты со скруглёнными углами, наклонённые в пространстве. **Внутри каждой играет видео проекта** — вот откуда «красота». Между ними и вокруг — плотные объёмные «кораллы» из мелких точек: пурпур, синь, фиалка, белый. Камера летит МЕЖДУ панелями, они перекрывают друг друга |
|
||||
| **Построенная комната («лаборатория»)** | 11,7–16,5 | Настоящая геометрия: подвешенное металлическое кольцо-установка, десятки тонких свисающих тросов, светящаяся кристаллическая масса в центре, круглый постамент, битый камень, **мокрый отражающий пол**, бетонные пилоны по краям в тени, розово-сиреневая подсветка сверху |
|
||||
|
||||
**Постоянные накладки, всего две, обе мелкие:**
|
||||
|
||||
- сверху справа — пилюля с тонким контуром, «WORK —— CONTACT»;
|
||||
- снизу слева — «WHAT ARE YOU LOOKING FOR?» и пять строк со стрелками
|
||||
(`-> WEBSITES`, `-> INSTALLATIONS`, `-> XR / VR / AI`, `-> MULTIPLAYER`, `-> GAMES`),
|
||||
плюс пилюля-кнопка «ASK ME ANYTHING...». Моноширинный, сиреневый, малый кегль.
|
||||
|
||||
**Чего в эталоне НЕТ вообще:** шапки, меню-строки, подвала, карточек, сеток,
|
||||
секций, кнопок «наверх», разделителей. Всё содержание живёт объектами внутри сцены.
|
||||
|
||||
**Главный вывод, объясняющий четыре отклонённые сборки:** зеркальный материал в
|
||||
эталоне применён **скупо — на одном предмете** (кольцо). Основная красота держится не
|
||||
на хроме, а на **видео внутри панелей**, **объёмных облаках точек** и **построенной
|
||||
комнате с отражающим полом**. Мы четыре раза делали ставку на хром и получали белую
|
||||
ленту, а панели оставляли пустыми — то есть повторяли самое дешёвое и пропускали самое
|
||||
дорогое.
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
# 00 · ПРОМТ НОВОЙ СЕССИИ — лендинг Лидерры с чистого листа
|
||||
|
||||
> **Владельцу:** дайте новой сессии одну фразу —
|
||||
> **«Прочитай `docs/lending-sborka/00-PROMT-NOVOY-SESSII.md` целиком и работай по нему»**.
|
||||
> Всё остальное она возьмёт отсюда сама.
|
||||
|
||||
**Собрано 06.08.2026 вечером.** Сборка самодостаточна: всё нужное лежит в этой папке
|
||||
или по указанным путям. Ничего не нужно вспоминать из прошлых разговоров.
|
||||
|
||||
---
|
||||
|
||||
## 1. 🎯 ПЛАНКА. Прочитать первым и держать в голове всю работу
|
||||
|
||||
Это **не задача сделать сайт**. Это задача **произвести впечатление**.
|
||||
|
||||
Лендинг — единственное, что человек увидит о нас до того, как решит, иметь ли с нами
|
||||
дело. Он должен **шокировать**. Но:
|
||||
|
||||
> **Шокировать не кислотой, а дороговизной, технологичностью и элегантностью.**
|
||||
|
||||
| Чем шокируем | Чем НЕ шокируем |
|
||||
|---|---|
|
||||
| Дороговизной — видно, что вложены деньги и труд | Ядовитыми цветами |
|
||||
| Технологичностью — так на вебе почти никто не умеет | Мельтешением и шумом |
|
||||
| Элегантностью — ни одной лишней детали | Крикливостью и «эффектами ради эффектов» |
|
||||
| Тишиной и пустотой между тяжёлыми моментами | Плотно забитым экраном |
|
||||
|
||||
**Вау-эффект — безоговорочно.** Не «неплохо получилось», не «аккуратно», не «чисто».
|
||||
Человек должен на первых двух секундах понять, что попал не на обычный сайт.
|
||||
|
||||
**Планка сравнения — эталон.** Не «наш прошлый лендинг стал лучше», а **вровень с
|
||||
`activetheory.net`**. Если работа не выдерживает такого сравнения — она не готова.
|
||||
|
||||
### 🔴 Никакой плоскости
|
||||
|
||||
**Основа — видео-эталон.** Плоских решений нет ни в одном месте:
|
||||
ни секций, ни карточек, ни сеток, ни шапки, ни подвала.
|
||||
|
||||
Всё — объёмное, всё внутри мира, всё технологично и продвинуто.
|
||||
Если в работе появилась плоская вёрстка — это не «упрощение», это **другая работа**.
|
||||
|
||||
### 🔴 Токены не экономим, углы не срезаем
|
||||
|
||||
Прямое требование владельца, дословно: **«токены не экономим и углы не срезаем»**.
|
||||
|
||||
Это значит:
|
||||
|
||||
- **не делать «пока попроще, потом улучшим»** — потом не наступает;
|
||||
- **не подменять проверку рассуждением** — если не запускал, так и сказать;
|
||||
- **не сокращать разбор**, чтобы уложиться в ответ;
|
||||
- **не выдавать первый вариант за найденный** — первый вариант это всегда умолчание;
|
||||
- **переделывать столько раз, сколько нужно**, а не защищать сделанное.
|
||||
|
||||
Владелец **замечает срезанные углы** и останавливает работу. Так уже было.
|
||||
|
||||
---
|
||||
|
||||
## 2. ⏱ Режим работы смены
|
||||
|
||||
Владелец задал ритм — соблюдать:
|
||||
|
||||
- **Смена — не больше 200–300 тысяч токенов.**
|
||||
- **Останавливаться в логичных и законченных местах**, а не посреди дела.
|
||||
Законченное место — это когда сделанное можно описать одной фразой и оно
|
||||
переживёт перезапуск: «цепочка вкуса пройдена», «восемь образов нарисованы и
|
||||
показаны», «глава 3 собрана и принята».
|
||||
- **Устал или подошёл к границе — остановиться и сказать вслух.** Владелец сделает
|
||||
компакт, и работа продолжится. Это штатный ход, а не сбой.
|
||||
- **Перед остановкой — записать состояние на диск**, чтобы оно не жило только в
|
||||
разговоре. Всё существенное коммитить.
|
||||
|
||||
Не тянуть смену до последнего: качество падает раньше, чем кончается место, и
|
||||
именно тогда начинают срезаться углы.
|
||||
|
||||
---
|
||||
|
||||
## 3. Твоя роль
|
||||
|
||||
Ты **дирижёр**, а не исполнитель-одиночка.
|
||||
|
||||
Ты:
|
||||
|
||||
- разбираешься в задаче сам, до конца, ничего не принимая на веру;
|
||||
- **раздаёшь работу подчинённым** — скилам, субагентам, внешним моделям;
|
||||
- **принимаешь работу своей проверкой**, а не по их отчёту;
|
||||
- разговариваешь с владельцем и держишь его в курсе;
|
||||
- отвечаешь за деньги и за то, чтобы ничего не сломалось на боевом сервере.
|
||||
|
||||
Ты **не** пишешь всю сцену в одиночку, если её можно разложить на куски и раздать.
|
||||
|
||||
---
|
||||
|
||||
## 4. Задача
|
||||
|
||||
Построить новый лендинг Лидерры — **такой же по устройству, как эталон**
|
||||
`моя/лендинг/video-06-08-26-10-41.mov`.
|
||||
|
||||
Это **не страница**. Это **мир, по которому летит камера**. Прокрутка двигает камеру,
|
||||
а не ленту документа. Всё содержание живёт объектами внутри трёхмерной сцены.
|
||||
|
||||
Показ по адресу `liderra.ru/new`. Главную не трогать.
|
||||
|
||||
---
|
||||
|
||||
## 5. 🔴 Начинаем с чистого листа
|
||||
|
||||
Прямое требование владельца: **не привязываться к сегодняшнему состоянию.
|
||||
Старьё не тянем.**
|
||||
|
||||
Четыре сборки уже отклонены. Не смотреть их, не смотреть боевой лендинг, не смотреть
|
||||
старое ТЗ рекламной страницы. Опора — **только смысл и возможности портала** и
|
||||
**эталон**.
|
||||
|
||||
Полный список запретного — файл `07-izolyaciya-chto-ne-brat.md`. Прочитать его
|
||||
**раньше**, чем что-либо открывать.
|
||||
|
||||
---
|
||||
|
||||
## 6. Что прочитать и в каком порядке
|
||||
|
||||
Всё лежит рядом, в `docs/lending-sborka/`.
|
||||
|
||||
| # | Файл | Зачем |
|
||||
|---|---|---|
|
||||
| 1 | **`07-izolyaciya-chto-ne-brat.md`** | Первым. Чтобы случайно не открыть лишнего |
|
||||
| 2 | **`02-etalon-razbor-po-kadram.md`** | Что мы вообще строим. **Смотреть кадры глазами**, папка `etalon/` |
|
||||
| 3 | **`01-produkt-chto-prodayom.md`** | Всё содержание страницы, откуда берутся смыслы |
|
||||
| 4 | **`06-tehnicheskaya-baza-dokazannoe.md`** | Что уже проверено работающим. Не переоткрывать заново |
|
||||
| 5 | **`04-cepochka-vkusa.md`** | 🔑 **Двенадцать звеньев, которые делают из тебя дизайнера.** Проходить по порядку, целиком, до первой строки кода |
|
||||
| 6 | **`03-resursy-vneshnie-modeli.md`** | Генераторы картинок, объёма и видео: имена, поля, цены |
|
||||
| 7 | **`05-pravila-promtov-subagentam.md`** | Как ставить задачи подчинённым, с примерами |
|
||||
|
||||
**Смотреть картинки обязательно.** Описание словами эталон не заменяет, а владелец
|
||||
судит глазами.
|
||||
|
||||
⚠️ **Само видео эталона лежит вне git** — папка `моя/` в списке игнорируемых.
|
||||
Оно есть на рабочей машине владельца по пути `моя/лендинг/video-06-08-26-10-41.mov`.
|
||||
Если его там не окажется — попросить у владельца. Восемь картинок в папке `etalon/`
|
||||
лежат в git и доступны всегда; их для работы достаточно.
|
||||
|
||||
⚠️ **Рекламной картинки в папке `моя/лендинг/` больше нет** — владелец её убрал
|
||||
06.08 вечером, оставив только видео, со словами «эталон видео крутяк, делаем по нему».
|
||||
**Копия картинки сохранена** в `etalon/reklamnaya-kartinka-vladelca.png`.
|
||||
|
||||
**Разделение источников, чтобы не путаться:**
|
||||
|
||||
| Источник | Что из него берём |
|
||||
|---|---|
|
||||
| **Видео-эталон** | **Основа.** Устройство мира, движение камеры, вид, планка качества |
|
||||
| Рекламная картинка | Содержание, палитра, главный образ: точки → кольцо → воронка → капли → кабинет |
|
||||
| `01-produkt…` | Смыслы: что обещаем, что умеет кабинет |
|
||||
|
||||
При расхождении **побеждает видео**.
|
||||
|
||||
---
|
||||
|
||||
## 7. Что уже сделано за тебя — не переделывать
|
||||
|
||||
| Сделано | Где |
|
||||
|---|---|
|
||||
| Эталон разобран по кадрам, вытащены шесть полноразмерных | `etalon/` |
|
||||
| Причина «белой ленты» найдена и снята | `06` §1 |
|
||||
| Найден рабочий рецепт стекла (`transmission` не работает) | `06` §2 |
|
||||
| Изготовлен шрифт для объёмных **русских** букв | `landing-3d/public/fonts/` |
|
||||
| Доказано, что видео играет внутри панели | `06` §4 |
|
||||
| Добыты настоящие имена и поля всех моделей gen-api | `03` |
|
||||
| Измерены цены пяти моделей запуском | `03` §4 |
|
||||
| Готовые заготовки: две карты окружения, ролик, два предмета | `landing-3d/public/` |
|
||||
| Снимальщик экрана и запускальщик моделей | `landing-3d/tools/`, `03` §7 |
|
||||
|
||||
Рабочая проверка открывается так:
|
||||
|
||||
```bash
|
||||
cd landing-3d && npx vite --port 5233 --strictPort --host 127.0.0.1
|
||||
# затем http://127.0.0.1:5233/resursy.html
|
||||
```
|
||||
|
||||
Это **заготовки, а не образец вкуса.** Они доказывают, что средства работают.
|
||||
Красоту делать заново, по цепочке из `04`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Главное, что объясняет четыре провала
|
||||
|
||||
Разбор эталона по кадрам показал: там **четыре разных типа сцены**, и зеркальный
|
||||
материал применён **скупо — на одном предмете**. Красота держится на другом:
|
||||
|
||||
1. **внутри стеклянных панелей ИГРАЕТ ВИДЕО** — это половина хронометража ролика;
|
||||
2. **объёмные облака точек** («кораллы») между панелями;
|
||||
3. **построенная комната** с мокрым отражающим полом.
|
||||
|
||||
Мы четыре раза ставили на хром и оставляли панели пустыми. То есть повторяли самое
|
||||
дешёвое и пропускали самое дорогое.
|
||||
|
||||
🔑 **Если в панелях ничего не играет — работа не сделана**, сколько бы галочек ни стояло.
|
||||
|
||||
---
|
||||
|
||||
## 9. Три вопроса себе — задавать на каждом шаге
|
||||
|
||||
1. > **Это мир, по которому летит камера, — или страница с трёхмерной подложкой?**
|
||||
Четвёртая сборка отклонена словами «ты по сути фон заменил».
|
||||
|
||||
2. > **Это выдержит сравнение с эталоном — или только «лучше, чем было»?**
|
||||
«Лучше, чем было» — не планка.
|
||||
|
||||
3. > **Тут есть вау — или просто аккуратно?**
|
||||
Аккуратно нам не нужно.
|
||||
|
||||
---
|
||||
|
||||
## 10. Порядок работы
|
||||
|
||||
1. **Прочитать сборку**, посмотреть кадры и рекламную картинку глазами.
|
||||
2. 🔑 **Пройти цепочку вкуса `04-cepochka-vkusa.md` целиком, все двенадцать звеньев,
|
||||
до первой строки кода.** Это не «ознакомиться» — это работа: объявить направление
|
||||
вслух, поставить регуляторы, составить свой список признаков дешёвого, свести
|
||||
восемь глав в таблицу различий. В конце файла — список из одиннадцати галочек;
|
||||
пока хоть одна не отмечена, код писать рано.
|
||||
3. **Согласовать с владельцем состав восьми глав** — что в каждой и какой образ.
|
||||
Показать списком, коротко, простым языком.
|
||||
4. **Нарисовать образ каждой главы картинкой** (`imagegen-frontend-web` +
|
||||
`seedream-v5-pro`) и **дать владельцу выбрать**. Он не объясняет словами — он
|
||||
выбирает из показанного.
|
||||
5. **Назвать сумму** на генерацию и дождаться прямого «да».
|
||||
6. **Оживить выбранные образы** (`ltx-2`), пережать под веб.
|
||||
7. **Написать приёмку** и положить её **вне рабочей папки исполнителей**.
|
||||
8. **Раздать главы субагентам** по правилам из `05`.
|
||||
9. **Принять работу своей проверкой и глазами.**
|
||||
10. **Показать владельцу.** Выкат на `liderra.ru/new` — только после его «да».
|
||||
|
||||
Между пунктами — естественные места для остановки смены (см. §2).
|
||||
|
||||
---
|
||||
|
||||
## 11. Жёсткие условия
|
||||
|
||||
- 🔴 **Боевой `liderra.ru` — живые клиенты и деньги.** Главную не трогать.
|
||||
Контрольная сумма `5046a638f9e311f2c4b073721112b6b1`, сверять после любой работы:
|
||||
`curl -s https://liderra.ru/ | md5sum`.
|
||||
- 🔴 **Деньги тратить только после прямого «да» на названную сумму.** Баланс gen-api
|
||||
на 06.08 — **1776 ₽**. Смета на все восемь глав — около **1150 ₽**.
|
||||
- 🔴 **Ключи в файлы не писать.** Только переменной окружения.
|
||||
- 🔴 **Никаких настоящих личных данных** в сцене: ни телефонов, ни имён, ни компаний.
|
||||
- **Полный трёхмерный полёт на телефоне обязателен.** Плоского «мобильного режима» нет.
|
||||
- **Форм заявки и юридических ссылок нет** — так решил владелец.
|
||||
- **Счётчик Метрики заперт** на домен и путь `/`. Защиту не снимать.
|
||||
- **Кусков портала внутрь панелей нет** — содержание рисуется моделями.
|
||||
- **Правки файлов только Edit/Write**, не из-под Bash.
|
||||
- **Git-задачи субагентам — только Sonnet или Opus**, никогда Haiku.
|
||||
|
||||
---
|
||||
|
||||
## 12. Как разговаривать с владельцем
|
||||
|
||||
- Он **не программист**. Простой русский, без программистских слов.
|
||||
- Он **не умеет объяснять словами, что не так, — он умеет выбрать из показанного.**
|
||||
Значит: показывать картинки и варианты, а не описания.
|
||||
- Он **замечает срезанные углы** и останавливает работу. Не выдавать догадку за
|
||||
замер. Если не проверял — так и сказать.
|
||||
- «Готово» — только если правда проверено.
|
||||
- Упёрся в стену — остановиться и спросить, а не искать обход.
|
||||
- Устал — сказать прямо. Он сделает компакт, и работа продолжится.
|
||||
|
||||
---
|
||||
|
||||
## 13. Что осталось непроверенным — честный список
|
||||
|
||||
1. **Скорость на настоящем телефоне.** На рабочей машине видеокарты нет.
|
||||
2. **Зацикливание видео** — 6-секундный ролик на стыке даёт рывок. Не пробовали.
|
||||
3. **Прореживание тяжёлых моделей** (7,4 МБ у `rodin`). Не пробовали.
|
||||
4. **Шов карты окружения** по краям панорамы. Полного оборота камеры не делали.
|
||||
5. **Точный состав шести рекламных каналов** — выведен из ключей расхода,
|
||||
у владельца не переспрошен.
|
||||
6. Цены моделей, кроме пяти измеренных.
|
||||
|
||||
---
|
||||
|
||||
## 14. Первое действие
|
||||
|
||||
1. Прочитать `07-izolyaciya-chto-ne-brat.md`.
|
||||
2. Открыть папку `etalon/` и **посмотреть все восемь картинок глазами**.
|
||||
3. Доложить владельцу **своими словами**: что увидел, как понял задачу и какую планку
|
||||
себе поставил.
|
||||
4. Дождаться его подтверждения.
|
||||
|
||||
**Не начинать строить, пока он не подтвердит, что ты понял правильно.**
|
||||
|
||||
---
|
||||
|
||||
## Состав сборки
|
||||
|
||||
```text
|
||||
docs/lending-sborka/
|
||||
├── 00-PROMT-NOVOY-SESSII.md ← этот файл
|
||||
├── 01-produkt-chto-prodayom.md смысл и содержание
|
||||
├── 02-etalon-razbor-po-kadram.md устройство эталона
|
||||
├── 03-resursy-vneshnie-modeli.md генераторы: имена, поля, цены
|
||||
├── 04-cepochka-vkusa.md ← двенадцать звеньев вкуса, проходить целиком
|
||||
├── 05-pravila-promtov-subagentam.md как ставить задачи, кому что поручать
|
||||
├── 06-tehnicheskaya-baza-dokazannoe.md что доказано работающим
|
||||
├── 07-izolyaciya-chto-ne-brat.md с чем не связываться
|
||||
└── etalon/
|
||||
├── list-32-kadra.jpg весь ролик
|
||||
├── kadr-01-pustota.jpg … kadr-06-komnata-lab.jpg
|
||||
└── reklamnaya-kartinka-vladelca.png
|
||||
|
||||
landing-3d/ рабочая проверка, всё уже работает
|
||||
├── resursy.html + src/resursy.js все возможности на одном экране
|
||||
├── public/env/, public/video/, public/models/, public/fonts/, public/tex/
|
||||
└── tools/shot.mjs, tools/font2typeface.mjs
|
||||
```
|
||||
@@ -0,0 +1,128 @@
|
||||
# 01 · Продукт: что именно мы продаём
|
||||
|
||||
**Это единственный источник смысла для лендинга.** Всё содержание страницы берётся
|
||||
отсюда и из рекламной картинки владельца. Ни из какого другого места содержание брать
|
||||
нельзя — см. `07-izolyaciya-chto-ne-brat.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Одной фразой
|
||||
|
||||
**Лидерра** — инструмент для поиска потенциальных клиентов, управления объёмом и
|
||||
бюджетом через единый личный кабинет.
|
||||
|
||||
Формулировка взята дословно с рекламной картинки владельца
|
||||
(`моя/лендинг/image-06-08-26-10-41.png`), которую он утвердил как источник содержания.
|
||||
|
||||
## 2. Заголовок, утверждённый владельцем
|
||||
|
||||
> **Находите потенциальных клиентов там, где они уже выбирают.**
|
||||
|
||||
Третья строка («там, где они уже выбирают») на картинке выделена бирюзовым — это
|
||||
смысловое ударение, его сохранять.
|
||||
|
||||
## 3. Три источника контактов
|
||||
|
||||
Подтверждено ограничением в самой базе данных (`db/schema.sql`,
|
||||
`CHECK (channel IN ('sites','calls','sms'))`) и подписями на рекламной картинке:
|
||||
|
||||
| Источник | Что это |
|
||||
|---|---|
|
||||
| **Сайты** | Заявки с сайтов |
|
||||
| **Телефоны** | Телефонные обращения |
|
||||
| **SMS** | Отклики на СМС |
|
||||
|
||||
## 4. Обещания продукта — дословно с рекламной картинки
|
||||
|
||||
Это готовые смысловые блоки. Они и есть содержание глав лендинга.
|
||||
|
||||
1. **Оплата только за нужное количество контактов**
|
||||
2. **Удобный личный кабинет**
|
||||
3. **Уникальные контакты без дублей**
|
||||
4. **Встроенная CRM и контроль сделок**
|
||||
5. **Рекламные инструменты для привлечения клиентов:** SMS, Telegram, VK, Яндекс.Аудитории
|
||||
6. **Прозрачная статистика и полный контроль расходов**
|
||||
7. **Настройте поиск клиентов** (любые сайты и телефоны)
|
||||
8. **Полная статистика в реальном времени**
|
||||
9. **Стоимость контакта — от 25 ₽**, без обязательных пакетов и скрытых платежей
|
||||
10. **Тестовый доступ без риска**
|
||||
11. **Быстрый старт — уже сегодня**
|
||||
|
||||
Кнопка действия на картинке: **«ПОПРОБОВАТЬ СЕРВИС»**.
|
||||
|
||||
## 5. Что клиент видит в кабинете — настоящие разделы
|
||||
|
||||
Снято с карты разделов приложения (`app/resources/js/router/index.ts`), а не с описания.
|
||||
|
||||
| Раздел | Что делает |
|
||||
|---|---|
|
||||
| **Дашборд** | Сводка: новые контакты, динамика, источники, последние контакты |
|
||||
| **Сделки** | Таблица всех контактов с поиском, фильтрами, экспортом |
|
||||
| **Канбан** | Доска с перетаскиванием карточек по стадиям |
|
||||
| **Проекты** | Настройка того, какие контакты и откуда собирать |
|
||||
| **Конкурентное поле** | Разведка по нише |
|
||||
| **Биллинг и тарифы** | Пополнение, расход, тарифная ступень |
|
||||
| **Отчёты** | Конверсия, эффективность, выгрузки |
|
||||
| **Импорт данных** | Загрузка своей базы |
|
||||
| **Настройки** | Профиль, доступы, двухфакторная проверка |
|
||||
| **Реклама → Телеграм** | Рекламные кампании в Телеграме |
|
||||
| **Реклама → Яндекс Аудитория** | Загрузка аудиторий в Яндекс |
|
||||
| **Реклама → Рассылка СМС** | СМС-рассылка по своей базе |
|
||||
| **Реклама → Рекламный кошелёк** | Общий бюджет на все рекламные каналы |
|
||||
|
||||
## 6. Воронка
|
||||
|
||||
**14 стадий.** Канон списка — `db/schema.sql` (строка с перечнем статусов) и §6.4
|
||||
главного ТЗ. Для лендинга важна не сама россыпь названий, а факт: контакт проходит
|
||||
путь от «новый» до «сделка закрыта», и этот путь виден.
|
||||
|
||||
## 7. Рекламные каналы
|
||||
|
||||
Владелец решил ранее: **шесть каналов, без пометок «в разработке»**.
|
||||
|
||||
По коду портала подтверждаются: **СМС**, **Телеграм**, **Яндекс.Аудитории**,
|
||||
**ВКонтакте**, **МТС**, **ИИ-обзвон** (в кошельке расход разложен по ключам
|
||||
`sms`, `telegram`, `yandex`, `vk`, `mts`, `obzvon`).
|
||||
|
||||
⚠️ **Точный состав шестёрки уточнить у владельца перед вёрсткой** — я вывел его из
|
||||
ключей расхода, а не из его прямого перечисления.
|
||||
|
||||
## 8. Деньги
|
||||
|
||||
- **От 25 ₽ за контакт** — цифра владельца с рекламной картинки, её и показывать.
|
||||
- **Без обязательных пакетов и скрытых платежей.**
|
||||
- 🔴 **В `app/database/seeders/PricingTierSeeder.php` лежат ТЕСТОВЫЕ цены 500…250 ₽.
|
||||
По ним не сверять и на лендинг не выносить.**
|
||||
|
||||
## 9. Чем мы лучше — семь отличий
|
||||
|
||||
Из §1.4.1 главного ТЗ, зафиксировано по итогам аудита конкурента. Годится как
|
||||
содержание отдельной главы, если она понадобится.
|
||||
|
||||
1. Двусторонний обмен данными с CRM клиента (у конкурента только приём).
|
||||
2. Полный журнал изменений по каждой сделке.
|
||||
3. Канбан-доска с перетаскиванием (у конкурента только таблица).
|
||||
4. Мгновенные уведомления без перезагрузки страницы.
|
||||
5. Дашборд с настраиваемыми блоками.
|
||||
6. REST API для интеграций.
|
||||
7. Безопасность аккаунта: двухфакторная проверка на всех тарифах, журнал входов,
|
||||
список активных сеансов, резервные коды — **у конкурента нет ничего из этого**.
|
||||
|
||||
## 10. Чего продукт НЕ делает — не обещать
|
||||
|
||||
- Не собирает лиды сам из воздуха — данные приходят из подключённых источников.
|
||||
- Нет встроенной телефонии.
|
||||
- Пополнение баланса — вручную клиентом.
|
||||
|
||||
## 11. Правовая рамка лендинга
|
||||
|
||||
- **Форм заявки на странице нет. Юридических ссылок нет.** Так решил владелец, риск
|
||||
принят им осознанно. Не добавлять по своей инициативе.
|
||||
- Кнопка ведёт в кабинет, а не в форму.
|
||||
- Счётчик Яндекс.Метрики на образце заперт: включается только на домене `liderra.ru`
|
||||
и только на `/`. **Эту защиту не снимать.**
|
||||
|
||||
## 12. Личные данные
|
||||
|
||||
В сцене не может быть ни одного настоящего телефона, имени, названия компании или
|
||||
номера сделки. Только выдуманные образцы.
|
||||
@@ -0,0 +1,175 @@
|
||||
# 02 · Эталон: что там на самом деле построено
|
||||
|
||||
**Эталон** — запись экрана сайта `activetheory.net`, файл
|
||||
`моя/лендинг/video-06-08-26-10-41.mov`. Длина **19,16 с**, снято с телефона
|
||||
(888×1920, 55,6 кадра/с, кодек H.265).
|
||||
|
||||
**Кадры лежат рядом, в папке `etalon/`** — смотреть их обязательно, прежде чем
|
||||
что-либо делать. Описание словами эталон не заменяет.
|
||||
|
||||
| Файл | Что на нём |
|
||||
|---|---|
|
||||
| `list-32-kadra.jpg` | Весь ролик, 32 кадра подряд — общее устройство |
|
||||
| `kadr-01-pustota.jpg` | Пустота с частицами (1,5 с) |
|
||||
| `kadr-02-zagolovok-hrom.jpg` | Заголовок и хромовое кольцо (3,3 с) |
|
||||
| `kadr-03-paneli-nachalo.jpg` | Первые панели (5,7 с) |
|
||||
| `kadr-04-paneli-blizko.jpg` | Панели вблизи, видно устройство (8,0 с) |
|
||||
| `kadr-05-paneli-hogwarts.jpg` | Галерея панелей (9,9 с) |
|
||||
| `kadr-06-komnata-lab.jpg` | Построенная комната (12,9 с) |
|
||||
| `reklamnaya-kartinka-vladelca.png` | Наш зрительный язык и содержание |
|
||||
|
||||
## Как открыть само видео
|
||||
|
||||
Chrome на рабочей машине **не декодирует H.265** — `videoWidth` равен нулю, кадры
|
||||
выходят пустыми. Штатный ffmpeg из Playwright тоже отказывает («Invalid data found»).
|
||||
|
||||
Рабочий способ:
|
||||
|
||||
```bash
|
||||
npm i ffmpeg-static # во ВРЕМЕННУЮ папку, не в проект; ставится за 8 секунд
|
||||
# затем
|
||||
<путь>/ffmpeg.exe -ss 3.29 -i video.mov -frames:v 1 \
|
||||
-vf "crop=860:586:14:672,scale=1290:-1:flags=lanczos" -q:v 2 -y kadr.jpg
|
||||
```
|
||||
|
||||
Обрезка `crop=860:586:14:672` вырезает окно браузера из съёмки телефоном.
|
||||
|
||||
---
|
||||
|
||||
## Устройство ролика — четыре разных типа сцены
|
||||
|
||||
Это главное открытие разбора. Эталон — **не одна сцена**, а четыре, и они устроены
|
||||
по-разному. Прошлые попытки повторяли только одну из них, самую дешёвую.
|
||||
|
||||
### Кадр 0 (0,3 с) — шутка ролика
|
||||
|
||||
Первый кадр — **чужой убогий сайт** с красно-жёлтыми таблицами и подписью «УБОГИЙ
|
||||
ДИЗАЙН CLAUDE CODE». Это «до». Дальше идёт «после». К нашей работе отношения не имеет,
|
||||
но объясняет, зачем владелец прислал ролик.
|
||||
|
||||
### Тип 1 · Пустота с частицами — 0,9–2,7 с и 17,1–18,9 с
|
||||
|
||||
- Почти чёрный фон, глубокая синева.
|
||||
- Редкие светящиеся точки, длинные шлейфы от них.
|
||||
- Тонкое кольцо-линза вдалеке.
|
||||
- **Ролик замыкается в кольцо: конец равен началу.**
|
||||
|
||||
### Тип 2 · Заголовок с одним хромовым кольцом — 3,3–4,5 с
|
||||
|
||||
Смотреть `kadr-02-zagolovok-hrom.jpg`. Устройство:
|
||||
|
||||
- **Буквы «CREATIVE DIGITAL EXPERIENCES»** — широкие, тонкие, вытянутые в глубину.
|
||||
Бело-серые, **матовые, с видимой боковой гранью**. Они **НЕ зеркальные**.
|
||||
- **Одно хромовое кольцо** рядом — вот оно зеркальное, с сиренево-синим переливом.
|
||||
Внутри кольца стеклянный знак с сильным преломлением.
|
||||
- Справа — мелкий моноширинный текст в шесть строк, низкая яркость, серо-голубой.
|
||||
- Внизу кадра **уже видна следующая сцена** — красная и синяя панели выглядывают
|
||||
снизу. Мир непрерывный, склейки нет.
|
||||
|
||||
🔑 **Ключевой вывод: зеркальный материал применён на ОДНОМ предмете.** Всё остальное
|
||||
матовое. Мы четыре раза делали наоборот.
|
||||
|
||||
### Тип 3 · Галерея панелей — 5,1–11,1 с, самая длинная часть
|
||||
|
||||
Смотреть `kadr-04-paneli-blizko.jpg` — там видно устройство вблизи.
|
||||
|
||||
- **Крупные полупрозрачные плиты** со скруглёнными углами, наклонённые в пространстве
|
||||
под разными углами. Толстая стеклянная кромка, видно преломление по краю.
|
||||
- **Внутри каждой плиты играет ВИДЕО проекта.** Именно поэтому кадры выглядят как
|
||||
размытые живые снимки. Это половина всей красоты ролика.
|
||||
- **Между плитами и вокруг них — плотные объёмные «кораллы»** из мелких точек:
|
||||
пурпур, синь, фиалка, белый. Они занимают объём, обвивают панели спереди и сзади.
|
||||
- **Камера летит МЕЖДУ панелями**, они перекрывают друг друга и уезжают с разной
|
||||
скоростью.
|
||||
- Названия проектов набраны широким плотным шрифтом прямо на плите.
|
||||
|
||||
Названия, которые видны: CHILE 20 · PROMETHEUS · MISSION · MILLION PIECE MISSION ·
|
||||
FRONTIER WITCHES · 2024 WEBSITE OF THE YEAR · TRACER · WELCOME TO HOGWARTS ·
|
||||
DISCOVER YOUR PATRONUS · PAPER PLANES.
|
||||
|
||||
### Тип 4 · Построенная комната — 11,7–16,5 с
|
||||
|
||||
Смотреть `kadr-06-komnata-lab.jpg`. Настоящая геометрия, а не частицы:
|
||||
|
||||
- Подвешенное сверху металлическое кольцо-установка.
|
||||
- Десятки тонких свисающих тросов.
|
||||
- Светящаяся кристаллическая масса в центре, с тёмным ядром.
|
||||
- Круглый постамент под ней.
|
||||
- Битый камень вокруг.
|
||||
- **Мокрый отражающий пол** — в нём видны блики.
|
||||
- Бетонные пилоны по краям, в тени.
|
||||
- Розово-сиреневая подсветка сверху.
|
||||
|
||||
Дальше — сетчатые поверхности с подписью «THE LAB», жёлто-зелёная биолюминесцентная
|
||||
сцена, синяя вспышка частиц, и возврат в пустоту.
|
||||
|
||||
---
|
||||
|
||||
## Постоянные накладки — всего две, обе мелкие
|
||||
|
||||
1. **Сверху справа** — пилюля с тонким контуром: `WORK —— CONTACT`.
|
||||
2. **Снизу слева** — `WHAT ARE YOU LOOKING FOR?` и пять строк со стрелками:
|
||||
`-> WEBSITES`, `-> INSTALLATIONS`, `-> XR / VR / AI`, `-> MULTIPLAYER`, `-> GAMES`,
|
||||
плюс пилюля-кнопка `ASK ME ANYTHING...`. Моноширинный, сиреневый, малый кегль.
|
||||
|
||||
Обе накладки — плоские, поверх сцены, не двигаются вместе с камерой.
|
||||
|
||||
## Чего в эталоне НЕТ ВООБЩЕ
|
||||
|
||||
Шапки. Строки меню. Подвала. Карточек. Сеток. Секций. Кнопок «наверх».
|
||||
Разделителей. Полос прокрутки как элемента оформления.
|
||||
|
||||
**Всё содержание живёт объектами внутри трёхмерной сцены.** Прокрутка двигает камеру
|
||||
по миру, а не ленту документа.
|
||||
|
||||
---
|
||||
|
||||
## Почему четыре наши сборки были отклонены
|
||||
|
||||
| № | Что сделали | Слова владельца |
|
||||
|---|---|---|
|
||||
| 1 | Перекрасили старый лендинг | «просто перелопатил наш лендинг» |
|
||||
| 2 | Повторили устройство плоскими средствами | хром вышел белой лентой |
|
||||
| 3 | Настоящий three.js: стекло, хром, перелив | «так гавно это все» |
|
||||
| 4 | Мягкие материалы, но обычная страница с трёхмерным фоном | «ты по сути фон заменил» |
|
||||
|
||||
**Разбор причин:**
|
||||
|
||||
- Сборка 2 и 3 провалились по технической причине: **зеркальному металлу нечего было
|
||||
отражать**, карты окружения не было. Причина найдена и снята — см.
|
||||
`06-tehnicheskaya-baza-dokazannoe.md`.
|
||||
- Сборка 4 провалилась по устройству: сделали **страницу с трёхмерной подложкой**
|
||||
вместо **мира, по которому летит камера**.
|
||||
- Все четыре пропустили главное: **внутри панелей ничего не играло**.
|
||||
|
||||
---
|
||||
|
||||
## Наш зрительный язык — рекламная картинка владельца
|
||||
|
||||
Смотреть `reklamnaya-kartinka-vladelca.png`. Это не эталон устройства, это эталон
|
||||
**цвета и образа**. Что на ней:
|
||||
|
||||
- Почти чёрный фон.
|
||||
- Слева — знак «ЛИДЕРРА» из узлов и линий, заголовок, подписи каналов, плитки
|
||||
преимуществ, цена «от 25 ₽», кнопка «ПОПРОБОВАТЬ СЕРВИС».
|
||||
- Справа — **главный образ**: россыпь бирюзовых точек поднимается вверх → собирается
|
||||
в светящееся кольцо-эллипс → проходит вниз через полупрозрачную **воронку** →
|
||||
превращается в **золотисто-оранжевые капли** → капли падают в планшет с кабинетом.
|
||||
- Планшет показывает дашборд: «Новые контакты 1 256 +21%», «Динамика», «Источники»
|
||||
кольцевой диаграммой, «Последние контакты».
|
||||
- Вокруг — плашки-выноски с обещаниями.
|
||||
|
||||
**Цвета:**
|
||||
|
||||
| Роль | Цвет |
|
||||
|---|---|
|
||||
| Фон | `#012019` — почти чёрный теало-нуар |
|
||||
| Основной | `#0F6E56` — тёмная бирюза |
|
||||
| Светлый акцент | `#4FD1A5` — светящаяся бирюза |
|
||||
| Текст | `#F6F3EC` — тёплая слоновая кость |
|
||||
| Второй акцент | золотисто-оранжевый — **только на «контактах»**, на каплях |
|
||||
|
||||
**Шрифты:** Unbounded (заголовки) + Onest (текст). Оба с кириллицей.
|
||||
|
||||
🔑 **Главный образ картинки — «точки → кольцо → воронка → капли → кабинет» — это
|
||||
готовый сценарий полёта камеры.** Он уже утверждён владельцем и не требует выдумывания.
|
||||
@@ -0,0 +1,266 @@
|
||||
# 03 · Внешние ресурсы: генераторы картинок, объёма и видео
|
||||
|
||||
Всё ниже **проверено запуском** 06.08.2026, не взято из документации. Где не
|
||||
проверено — так и написано.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ключи
|
||||
|
||||
| Служба | Где взять |
|
||||
|---|---|
|
||||
| **gen-api.ru** | Ключ у владельца, начинается на `sk-6Sz4…`. Счёт на имя «Саша Кондратьева» |
|
||||
| **aitunnel** (GPT-5.6, Perplexity) | Ключ у владельца, начинается на `sk-aitunnel-…` |
|
||||
|
||||
🔴 **Ключи в файлы не писать никогда.** Передавать только переменной окружения:
|
||||
`export GENAPI_KEY='…'`. В репозитории уже лежит один настоящий старый ключ aitunnel
|
||||
в `docs/superpowers/plans/2026-06-30-KONTEKST-portal-live.md` — не тиражировать его.
|
||||
|
||||
**Баланс gen-api на 06.08.2026 вечер: 1776 ₽.** Проверять командой перед работой.
|
||||
|
||||
---
|
||||
|
||||
## 2. Как разговаривать с gen-api
|
||||
|
||||
### Запуск
|
||||
|
||||
```text
|
||||
POST https://api.gen-api.ru/api/v1/networks/<слуг>
|
||||
Authorization: Bearer <ключ>
|
||||
Content-Type: application/json
|
||||
→ {"request_id": 51280057, "model": "ltx-2", "status": "processing"}
|
||||
```
|
||||
|
||||
### Забрать результат
|
||||
|
||||
```text
|
||||
GET https://api.gen-api.ru/api/v1/request/get/<request_id>
|
||||
→ статус processing → … → success, и поле result со ссылками
|
||||
```
|
||||
|
||||
Это **очередь**: ответ приходит не сразу даже при `is_sync: true`. Для выпечки
|
||||
заготовок это неважно.
|
||||
|
||||
### Свой счёт
|
||||
|
||||
```text
|
||||
GET https://api.gen-api.ru/api/v1/user → поле balance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 🔑 Как узнать настоящие названия моделей и их поля
|
||||
|
||||
**Это не описано публично.** Найдено чтением кода самого сайта. Оба адреса
|
||||
**открытые, ключ не нужен**:
|
||||
|
||||
```text
|
||||
GET https://api.gen-api.ru/api/network/category/<раздел>/networks
|
||||
разделы: images, videos, 3d, text, audio
|
||||
→ полный список моделей раздела: slug, title, описание, среднее время
|
||||
|
||||
GET https://api.gen-api.ru/api/network/info/parameters/<слуг>
|
||||
→ какие поля модель просит, какие обязательные,
|
||||
какие значения допустимы (списками), что по умолчанию
|
||||
```
|
||||
|
||||
Обратите внимание: путь `/api/network/…`, **без** `v1`. У запуска генерации путь
|
||||
другой — `/api/v1/networks/…`.
|
||||
|
||||
🔴 **Названия моделей НЕ УГАДЫВАТЬ.** Прошлая смена угадывала (`tripo`, `meshy`,
|
||||
`veo`), получала 404 и сделала вывод, что моделей на счёте нет. Это было неверно:
|
||||
404 означает только неправильно угаданное имя. Настоящие названия — `tripo-3d`,
|
||||
`meshy-v6`, `seedream-v5-pro` и так далее.
|
||||
|
||||
Страница `gen-api.ru/models` рисуется скриптом — `WebFetch` списки не видит.
|
||||
Через два адреса выше всё берётся без браузера.
|
||||
|
||||
---
|
||||
|
||||
## 4. Что измерено запуском
|
||||
|
||||
| Модель | Что даёт | Цена | Ждать | Формат |
|
||||
|---|---|---|---|---|
|
||||
| `seedream-v5-pro` | Картинка 2048×1024 | **16,88 ₽** | 90 с | JPEG по ссылке |
|
||||
| `tripo-3d` | Объёмный предмет по описанию | **50,00 ₽** | 163 с | GLB, 1,1 МБ |
|
||||
| `rodin` | Органика по описанию | **~80 ₽** | 90 с | GLB, **7,4 МБ — тяжело** |
|
||||
| `ltx-2` | Видео 6 с, 1920×1080, 25 к/с | **60,00 ₽** | 41 с | MP4 H.264, 16,9 МБ |
|
||||
| `midas` | Карта глубины по картинке | копейки | 9 с | PNG |
|
||||
|
||||
**Отказ по неверным полям денег не стоит.** Приходит 422 со списком того, что не так.
|
||||
|
||||
### Что делать с видео
|
||||
|
||||
Сырое видео 16,9 МБ никуда не годится. Пережимать:
|
||||
|
||||
```bash
|
||||
ffmpeg -i v.mp4 -an -vf "scale=640:-2" -c:v libx264 -crf 30 -preset slow \
|
||||
-movflags +faststart -y v_web.mp4 # → 562 КБ
|
||||
```
|
||||
|
||||
Звук убирать (`-an`) — он не нужен и весит. VP9/WebM пробовали, вышло **больше**
|
||||
(980 КБ), так что H.264.
|
||||
|
||||
### Что делать с тяжёлыми моделями
|
||||
|
||||
`rodin` отдаёт 7,4 МБ. Это много. Прореживать сетку (не пробовали — открытый вопрос).
|
||||
`tripo-3d` с полем `face_limit` сразу отдаёт лёгкую модель — 1,1 МБ.
|
||||
|
||||
---
|
||||
|
||||
## 5. Полезные поля моделей — снято адресом из §3
|
||||
|
||||
### `seedream-v5-pro` — картинки
|
||||
|
||||
| Поле | Обяз. | Значения |
|
||||
|---|---|---|
|
||||
| `prompt` | **да** | текст |
|
||||
| `width`, `height` | нет | числа, по умолчанию 1024×1024. **2048×1024 принято** |
|
||||
| `num_images` | нет | по умолчанию 1 |
|
||||
| `image_urls` | нет | ссылки-образцы |
|
||||
| `output_format` | нет | `jpeg` / `png` |
|
||||
| `translate_input` | нет | 💰 платно, ~2 ₽ за 1000 знаков — **ставить `false`, промт писать сразу по-английски** |
|
||||
|
||||
### `nano-banana-pro` — картинки, соотношение списком
|
||||
|
||||
`aspect_ratio` из списка: `auto, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16`.
|
||||
`resolution`: `1K / 2K / 4K`.
|
||||
|
||||
### `tripo-3d` — объём
|
||||
|
||||
| Поле | Значения |
|
||||
|---|---|
|
||||
| `prompt` | текст. **Либо текст, либо картинки — вместе нельзя** |
|
||||
| `image_urls` | ссылки |
|
||||
| `face_limit` | по умолчанию 10000 — им и правится вес |
|
||||
| `texture`, `pbr` | по умолчанию включены |
|
||||
| `texture_quality`, `geometry_quality` | `standard` / `detailed` |
|
||||
|
||||
### `rodin` — органика
|
||||
|
||||
`quality`: `high / medium / low / extra-low` · `geometry_file_format`: `glb, usdz, fbx, obj, stl` ·
|
||||
`material`: `PBR / Shaded` · `high_resolution`: да/нет.
|
||||
**Для облегчения — начинать с `quality: low` и `high_resolution: false`.**
|
||||
|
||||
### `ltx-2` — видео
|
||||
|
||||
| Поле | Значения |
|
||||
|---|---|
|
||||
| `prompt` | **обязательно** |
|
||||
| `mode` | `fast` / `pro` |
|
||||
| `duration` | `6, 8, 10, 12, 14, 16, 18, 20` (в режиме `pro` только 6, 8, 10) |
|
||||
| `resolution` | `1080p / 1440p / 2160p` |
|
||||
| `aspect_ratio` | только `16:9` |
|
||||
| `fps` | `25` / `50` |
|
||||
| `image_url` | первый кадр (не работает в режиме `standard`) |
|
||||
|
||||
### `midas` — глубина
|
||||
|
||||
Одно поле: `image` (ссылка или файл).
|
||||
|
||||
---
|
||||
|
||||
## 6. Что ещё есть в каталоге — не пробовано, но названия настоящие
|
||||
|
||||
**Объём (8 моделей):** `trellis`, `trellis-2`, `rodin`, `hunyuan-3d`,
|
||||
`hunyuan-3d-multi-view`, `hunyuan-motion`, `meshy-v6`, `tripo-3d`.
|
||||
|
||||
**Видео (66):** `ltx-2`, `ltx-2-3`, `pixverse-v6`, `seedance-lite`,
|
||||
`seedance-1-5-pro`, `kling-video-2-6`, `veo-3-1-fast`, `sora-2`, `wan-2-6-video`,
|
||||
`grok-video`, `luma` (у неё единственной прямо указана цена — 30 ₽ за генерацию) и другие.
|
||||
|
||||
**Картинки (93):** `seedream-v5-pro/lite`, `nano-banana-pro/2/lite`, `flux-2-pro/max/flex/klein`,
|
||||
`gpt-image-2`, `imagen-4`, `recraft-v4`, `qwen-image-max`, `hunyuan-image-v3`.
|
||||
|
||||
**Особо полезные служебные:**
|
||||
|
||||
| Слуг | Что делает |
|
||||
|---|---|
|
||||
| `midas` | карта глубины по картинке — даёт объём плоскому |
|
||||
| `image-reframe` | расширяет картинку под другое соотношение сторон |
|
||||
| `iclight-v2` | переосвещает готовую картинку |
|
||||
| `image-2-svg` | векторизация |
|
||||
| `clarity-upscaler`, `seedvr` | увеличение |
|
||||
| `pixelcut-background-removal` | вырезание фона |
|
||||
|
||||
Полный список — `docs/superpowers/findings/2026-08-06-genapi-nastoyashchie-nazvaniya-modeley.md`.
|
||||
|
||||
⚠️ **Старый `flux` не умеет 2:1.** У него жёсткий список разрешений: 3136×1344,
|
||||
2048×2048, 2752×1536, 2368×1792, 1344×3136, 1536×2752, 1792×2368. Панораму делать
|
||||
на `seedream-v5-pro`.
|
||||
|
||||
⚠️ **Таблица цен по счёту закрыта веб-сессией** (`/api/account/personal/prices`
|
||||
отвечает «не авторизован» на ключ API). Цену каждой новой модели узнавать замером:
|
||||
снять баланс, запустить, снять снова.
|
||||
|
||||
---
|
||||
|
||||
## 7. Готовый запускальщик
|
||||
|
||||
Положить во временную папку, ключ — переменной окружения.
|
||||
|
||||
```js
|
||||
// gen.mjs — запуск и ожидание. node gen.mjs <слуг> <файл-с-телом.json> <имя-вывода>
|
||||
import fs from 'node:fs';
|
||||
const KEY = process.env.GENAPI_KEY;
|
||||
const base = 'https://api.gen-api.ru/api/v1';
|
||||
const H = { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||
const bal = async () => (await (await fetch(`${base}/user`, { headers: H })).json()).balance;
|
||||
|
||||
const [model, bodyPath, out = 'out'] = process.argv.slice(2);
|
||||
const body = JSON.parse(fs.readFileSync(bodyPath, 'utf8'));
|
||||
const b0 = await bal(); console.log('баланс до:', b0);
|
||||
|
||||
const started = await (await fetch(`${base}/networks/${model}`, {
|
||||
method: 'POST', headers: H, body: JSON.stringify(body),
|
||||
})).json();
|
||||
console.log('запуск:', JSON.stringify(started).slice(0, 300));
|
||||
const id = started.request_id;
|
||||
if (!id) process.exit(1);
|
||||
|
||||
let last;
|
||||
for (let i = 0; i < 150; i++) {
|
||||
await new Promise(r => setTimeout(r, 4000));
|
||||
last = await (await fetch(`${base}/request/get/${id}`, { headers: H })).json();
|
||||
if (['success', 'error', 'failed'].includes(last.status)) break;
|
||||
}
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify(last, null, 1));
|
||||
console.log('результат:', JSON.stringify(last.result).slice(0, 500));
|
||||
const b1 = await bal(); console.log(`списано: ${(b0 - b1).toFixed(2)} ₽`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Смета на восемь глав по измеренным ценам
|
||||
|
||||
| Статья | Расчёт | Сумма |
|
||||
|---|---|---|
|
||||
| Видео внутрь панелей, 8 глав | 8 × 60 ₽ | 480 ₽ |
|
||||
| Карты окружения, 2 шт. | 2 × 16,88 ₽ | 34 ₽ |
|
||||
| Предметы для комнаты, 3 шт. | 3 × 50 ₽ | 150 ₽ |
|
||||
| Органика, 2 шт. | 2 × 80 ₽ | 160 ₽ |
|
||||
| Запас на переделки, ~40 % | | 330 ₽ |
|
||||
| **Итого** | | **~1150 ₽** |
|
||||
|
||||
На счету 1776 ₽ — хватает.
|
||||
|
||||
🔴 **Тратить только после прямого «да» владельца на конкретную сумму.** Он дал добро
|
||||
словами «используя все ресурсы, в том числе любые модели», но перед каждой крупной
|
||||
серией лучше назвать сумму и дождаться подтверждения.
|
||||
|
||||
---
|
||||
|
||||
## 9. Разведка и рецензия через aitunnel
|
||||
|
||||
```text
|
||||
POST https://api.aitunnel.ru/v1/chat/completions
|
||||
Authorization: Bearer <ключ aitunnel>
|
||||
```
|
||||
|
||||
Рабочие модели: `gpt-5.6-sol`, `sonar-pro`, `sonar-deep-research`.
|
||||
⚠️ `sonar-reasoning-pro` обрывает ответ на середине — не брать.
|
||||
⚠️ `sonar-deep-research` тоже может оборваться при большом `max_tokens` — добивать
|
||||
вторым запросом к `sonar-pro`.
|
||||
|
||||
MCP-подключение к Perplexity **сломано** (бьётся в `/v1/v1/agent`) — ходить прямым
|
||||
запросом.
|
||||
@@ -0,0 +1,422 @@
|
||||
# 04 · Цепочка вкуса — как стать дизайнером, а не исполнителем
|
||||
|
||||
**Это самый важный файл сборки после эталона.** Здесь только то, что **формирует
|
||||
вкус и эстетику**. Всё служебное — работа с субагентами, git, приёмка — вынесено в
|
||||
`05`, чтобы не размывать.
|
||||
|
||||
Цепочка выстроена так, чтобы **покрыть все стороны вкуса** и не оставить дыр.
|
||||
Проходить **по порядку**, каждое звено — до следующего. Порядок не украшение:
|
||||
нельзя выбирать шрифт, пока не объявлено направление, и нельзя писать код, пока
|
||||
не нарисован образ.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Мерило, к которому сводятся все двенадцать звеньев
|
||||
|
||||
Владелец сформулировал планку так:
|
||||
|
||||
> **Шокировать — но не кислотой, а дороговизной, технологичностью и элегантностью.**
|
||||
|
||||
Это и есть проверка любого решения по ходу цепочки. К каждому найденному приёму
|
||||
задавать три вопроса:
|
||||
|
||||
1. **Читается ли это как дорогое?** Не «яркое», не «необычное» — именно дорогое:
|
||||
видно вложенный труд, точность, выдержку.
|
||||
2. **Читается ли это как технологичное?** Так, чтобы человек подумал «а разве в
|
||||
браузере так можно».
|
||||
3. **Читается ли это как элегантное?** Элегантность — это когда убрать уже нечего.
|
||||
|
||||
Если приём даёт «ярко» и «заметно», но не даёт этих трёх, — он **кислотный**,
|
||||
и его выбрасывают, каким бы эффектным он ни казался.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Общее правило для всей цепочки
|
||||
|
||||
Все эти скилы написаны **про плоские сайты**: сетки, карточки, отступы, тени, секции.
|
||||
Наш лендинг — **трёхмерный мир, по которому летит камера**. Ни одного из этих понятий
|
||||
там нет.
|
||||
|
||||
**Из каждого скила берётся ПРИНЦИП и переводится в наш мир. Приёмы — не берутся.**
|
||||
|
||||
В конце файла — таблица перевода (§13). Читать её после каждого звена.
|
||||
|
||||
Если скил предлагает «сделай секцию с карточками» — это **сигнал, что мы сползаем
|
||||
обратно в обычную страницу**. Ровно на этом провалилась четвёртая сборка.
|
||||
|
||||
---
|
||||
|
||||
## Звено 1 · Прочитать задачу и объявить направление вслух
|
||||
|
||||
**Скил:** `taste-skill`, раздел 0 «Brief Inference» и раздел 1 «Три регулятора».
|
||||
|
||||
**Чему учит.** Плохой дизайн рождается не от нехватки умения, а оттого, что модель
|
||||
прыгает к своему умолчанию, не прочитав задачу. Лекарство — **до всякой работы
|
||||
произнести одну строку**: «Читаю это как: <что за вещь> для <кого>, с <каким>
|
||||
языком, с уклоном в <что>».
|
||||
|
||||
**Три регулятора**, которые ставятся сразу и правят все дальнейшие решения:
|
||||
|
||||
- `DESIGN_VARIANCE` 1–10 — от идеальной симметрии до художественного хаоса;
|
||||
- `MOTION_INTENSITY` 1–10 — от неподвижности до кинематографической физики;
|
||||
- `VISUAL_DENSITY` 1–10 — от художественной галереи до кабины самолёта.
|
||||
|
||||
**Наши значения — поставлены заранее, спорить с ними не нужно:**
|
||||
|
||||
| Регулятор | Значение | Почему |
|
||||
|---|---|---|
|
||||
| **Вариативность** | **9** | Эталон — студийная работа уровня Awwwards, симметрии в нём нет |
|
||||
| **Движение** | **10** | Прокрутка **есть** движение камеры. Без движения работы нет вовсе |
|
||||
| **Плотность** | **2** | Художественная галерея. Пустота — материал, а не пропуск |
|
||||
|
||||
**Наша строка направления, готовая:**
|
||||
|
||||
> «Читаю это как: **трёхмерный мир-полёт** для человека, который ищет клиентов и
|
||||
> ценит, когда видно, что деньги вложены; язык — **тёмный кинематографический,
|
||||
> бирюзовое свечение на чёрном**; уклон в **студийную работу Active Theory**,
|
||||
> а не в рекламную страницу».
|
||||
|
||||
**Что взять и перевести:** дисциплину «сначала направление вслух, потом работа».
|
||||
**Что отбросить:** «design system map» — Tailwind, shadcn, GOV.UK. У нас WebGL.
|
||||
|
||||
---
|
||||
|
||||
## Звено 2 · Выучить, что делает работу ДЕШЁВОЙ
|
||||
|
||||
**Скилы:** `soft-skill` раздел 2 «Absolute Zero», `taste-skill` раздел 9 «AI tells».
|
||||
|
||||
**Чему учит.** Есть короткий список признаков, по которым работа мгновенно читается
|
||||
как машинная и дешёвая. Их надо знать наизусть — не чтобы «стараться избегать», а
|
||||
чтобы **узнавать в своей работе**.
|
||||
|
||||
Из `soft-skill` дословно: *«Если в твоём коде есть ХОТЬ ЧТО-ТО из списка — дизайн
|
||||
провален немедленно»*. Запрещены: Inter, Roboto, Arial; толстые стандартные значки;
|
||||
серые рамки в 1 пиксель; жёсткие чёрные тени; липкая шапка во всю ширину; симметричные
|
||||
трёхколоночные сетки без воздуха; переходы `linear` и `ease-in-out`; мгновенная смена
|
||||
состояния без промежуточных кадров.
|
||||
|
||||
Из `taste-skill` — «следы машины»: фиолетовые градиенты, центрированный первый экран
|
||||
на тёмной сетке, три одинаковые плитки преимуществ, стекло на всём подряд,
|
||||
бесконечные мелкие анимации везде, Inter со слейт-900.
|
||||
|
||||
**Наш список дешёвого — переведённый в трёхмерный мир:**
|
||||
|
||||
| Признак дешёвого у нас | Почему |
|
||||
|---|---|
|
||||
| Зеркальный хром на всём подряд | Сборка 3 — «так гавно это все». В эталоне зеркальный **один** предмет |
|
||||
| Пустые панели | Главная причина всех четырёх отказов |
|
||||
| Одинаковые облака точек в разных цветах — и это все главы | Однообразие читается сразу |
|
||||
| Ровное вращение предмета вокруг оси с постоянной скоростью | Признак заставки, а не режиссуры |
|
||||
| Свечение, добавленное «чтобы было красиво» | Свет должен откуда-то исходить |
|
||||
| Одинаковый ритм: глава — экран, глава — экран | Эталон дышит: длинная галерея, короткая пустота |
|
||||
| Текст, плоско наложенный поверх сцены | Кроме двух накладок — всё внутри мира |
|
||||
| Плавное «ease-in-out» на всех движениях камеры | Камера имеет массу; она разгоняется и тормозит по-разному |
|
||||
|
||||
**Как пользоваться:** после каждого готового куска пройти по своему списку и честно
|
||||
отметить, чего в работе нет.
|
||||
|
||||
---
|
||||
|
||||
## Звено 3 · Ломать однообразие осознанно, а не «постараться»
|
||||
|
||||
**Скилы:** `soft-skill` раздел 3 «Creative Variance Engine»,
|
||||
`gpt-tasteskill` раздел 1 «Python-driven true randomization».
|
||||
|
||||
**Чему учит.** Модель ленива и всегда берёт первый вариант из списка. Лекарство —
|
||||
**до работы принудительно бросить жребий** и жёстко следовать выпавшему.
|
||||
`gpt-tasteskill` требует буквально смоделировать бросок и потом не отступать.
|
||||
|
||||
`soft-skill` даёт форму: выбрать **один** образ атмосферы и **один** образ композиции
|
||||
из готовых наборов, и уже их сочетание даёт неповторимость.
|
||||
|
||||
**Наш перевод — восемь глав обязаны различаться.** Заводим оси различия и требуем,
|
||||
чтобы **каждая пара соседних глав расходилась минимум по пяти осям из восьми**:
|
||||
|
||||
| # | Ось различия | Значения |
|
||||
|---|---|---|
|
||||
| 1 | Из чего сделана глава | точки · плиты · построенная геометрия · буквы · свет |
|
||||
| 2 | Где камера | внутри облака · снаружи · пролетает насквозь · стоит и смотрит |
|
||||
| 3 | Движение камеры | вперёд · вбок · вращение вокруг предмета · падение · подъём |
|
||||
| 4 | Масштаб | предмет крупнее камеры · соразмерен · россыпь мелочи |
|
||||
| 5 | Свет | сверху · сзади на просвет · изнутри предмета · холодный сбоку |
|
||||
| 6 | Плотность кадра | почти пусто · среднее · плотно забито |
|
||||
| 7 | Скорость | медленно и тягуче · ровно · рывком |
|
||||
| 8 | Что несёт смысл | буквы · образ в панели · сам предмет · поведение частиц |
|
||||
|
||||
**Проверка:** свести восемь глав в таблицу по этим осям и посмотреть глазами. Если
|
||||
две соседние строки похожи — переделать, не оправдываясь.
|
||||
|
||||
**Что отбросить:** «Bento», «Editorial Split», «Z-Axis Cascade» — это раскладки
|
||||
плоской страницы.
|
||||
|
||||
---
|
||||
|
||||
## Звено 4 · Типографика
|
||||
|
||||
**Скилы:** `gpt-tasteskill` раздел 3 «Железное правило двух строк»,
|
||||
`taste-skill` раздел 4.1, `imagegen-frontend-web` раздел 9 «Typography-first».
|
||||
|
||||
**Чему учит.**
|
||||
|
||||
- **Заголовок не больше 2–3 строк. Четыре и больше — провал.** Лечится не переносом,
|
||||
а **более широким полем и меньшим кеглем**: `clamp(3rem, 5vw, 5.5rem)`.
|
||||
- Узкое поле под заголовок — главная причина «стены текста в шесть строк».
|
||||
- **Запрещены дешёвые надписи-ярлыки**: «СЕКЦИЯ 01», «ВОПРОС 05», «О НАС». Убирать
|
||||
совсем.
|
||||
- Кегли должны стоять далеко друг от друга: крупное — очень крупное, мелкое — очень
|
||||
мелкое. Средних размеров быть почти не должно.
|
||||
|
||||
**Наш перевод:**
|
||||
|
||||
- Заголовок главы — **объёмными буквами внутри сцены**, максимум две строки,
|
||||
читается 2–3 секунды пролёта.
|
||||
- Мелкий слой — **моноширинный, малого кегля**, как подписи в эталоне. Это тот
|
||||
самый контраст кеглей.
|
||||
- Шрифты заданы: **Unbounded** (заголовки) и **Onest** (мелкое). Оба с кириллицей.
|
||||
Файл для объёмных букв уже изготовлен, см. `06` §3.
|
||||
- Никаких «ГЛАВА 3», «РАЗДЕЛ 2» в кадре.
|
||||
|
||||
---
|
||||
|
||||
## Звено 5 · Цвет, свет и материал
|
||||
|
||||
**Скилы:** `taste-skill` раздел 4.2 «Color Calibration», раздел 8 «Dark Mode»,
|
||||
`imagegen-frontend-web` раздел 13 «Color & Material».
|
||||
|
||||
**Чему учит.**
|
||||
|
||||
- **Чистый `#000000` и чистый `#ffffff` убивают глубину.** Брать смещённые
|
||||
почти-чёрный и почти-белый.
|
||||
- Контраст по норме доступности — не пожелание.
|
||||
- Иерархия, работающая в светлом, обязана работать и в тёмном.
|
||||
- Фирменный цвет не выцветать до неузнаваемости.
|
||||
|
||||
**Наш перевод — палитра уже задана и не обсуждается:**
|
||||
|
||||
| Роль | Цвет | Замечание |
|
||||
|---|---|---|
|
||||
| Фон мира | `#012019` | Смещённый почти-чёрный, а не `#000000` — ровно по правилу |
|
||||
| Основной | `#0F6E56` | |
|
||||
| Светящийся акцент | `#4FD1A5` | |
|
||||
| Текст | `#F6F3EC` | Тёплая слоновая кость, а не белый — тоже по правилу |
|
||||
| Второй акцент | золотисто-оранжевый | **Только на «контактах»**, на каплях. Больше нигде |
|
||||
|
||||
**Про свет — главное, что мы выучили на своей шкуре.** В трёхмерном мире цвет
|
||||
предмета решает не краска, а **то, что он отражает**. Зеркальный металл без карты
|
||||
окружения выходит белёсой лентой — так провалились две сборки. Подробности и
|
||||
рабочие рецепты — `06` §1 и §2.
|
||||
|
||||
**Что отбросить:** двойной режим светлый/тёмный. У нас мир один, тёмный.
|
||||
|
||||
---
|
||||
|
||||
## Звено 6 · Ритм и пустота
|
||||
|
||||
**Скилы:** `soft-skill` раздел 4.C «Spatial Rhythm & Tension»,
|
||||
`imagegen-frontend-web` раздел 10 «Section Rhythm» и раздел 12 «Density & Spacing».
|
||||
|
||||
**Чему учит.** *«Удвой обычный отступ. Дай работе тяжело дышать.»* Дорогое ощущение
|
||||
даёт не украшение, а **количество пустоты вокруг**. Дешёвое — это когда всё
|
||||
прижато друг к другу.
|
||||
|
||||
Плюс: ритм разделов не должен быть равномерным. Одинаковые по длине куски усыпляют.
|
||||
|
||||
**Наш перевод:**
|
||||
|
||||
- **Пустота — это участки пути камеры, где нет ничего.** Полторы–две секунды полёта
|
||||
сквозь темноту с редкими точками между тяжёлыми главами. Это не «потерянное время»,
|
||||
это воздух.
|
||||
- **Ритм глав неравномерный.** В эталоне галерея панелей занимает шесть секунд из
|
||||
девятнадцати, а пустота на входе — полторы. Наши восемь глав не могут быть по
|
||||
одному экрану каждая.
|
||||
- Плотность 2 из 10: **в кадре должно быть немного предметов, но крупных**.
|
||||
|
||||
---
|
||||
|
||||
## Звено 7 · Движение как масса, а не как переход
|
||||
|
||||
**Скилы:** `soft-skill` раздел 5 «Motion Choreography»,
|
||||
`gpt-tasteskill` раздел 5, `taste-skill` раздел 5.D и 6.B.
|
||||
|
||||
**Чему учит.**
|
||||
|
||||
- **Никаких `linear` и `ease-in-out`.** Любое движение обязано изображать вес и
|
||||
пружину: свои кривые вида `cubic-bezier(0.32, 0.72, 0, 1)`.
|
||||
- Ничто не появляется мгновенно и статично: входит со сдвигом, размытием и
|
||||
задержкой, каскадом.
|
||||
- Нажатие — физическое: предмет слегка проседает.
|
||||
- **Запрещено `window.addEventListener('scroll')`** — это жёсткий запрет, а не
|
||||
пожелание. Он даёт непрерывные пересчёты и убивает телефон.
|
||||
- **Уважение к `prefers-reduced-motion` обязательно.**
|
||||
- Двигать только то, что не заставляет пересчитывать раскладку.
|
||||
|
||||
**Наш перевод:**
|
||||
|
||||
- **Камера имеет массу.** Она не едет равномерно за прокруткой — она догоняет её с
|
||||
запаздыванием и мягко успокаивается. Резкая прокрутка не должна дёргать мир.
|
||||
- Прокрутка **не читается обработчиком события прокрутки**: положение берётся из
|
||||
накопленного смещения и сглаживается каждый кадр.
|
||||
- **Разные предметы движутся с разной скоростью** — ближние быстрее дальних. Это и
|
||||
есть настоящая глубина, и это проверяется числом (см. `05`).
|
||||
- При `prefers-reduced-motion` мир остаётся, но камера идёт строго по прокрутке,
|
||||
без инерции, без покачивания, без самостоятельных движений.
|
||||
|
||||
---
|
||||
|
||||
## Звено 8 · Сначала образ, потом код
|
||||
|
||||
**Скил:** `image-to-code-skill`, разделы 2, 9, 10.
|
||||
|
||||
**Чему учит.** Правило жёсткое: *«для визуально важных задач ты обязан СНАЧАЛА
|
||||
сгенерировать изображение дизайна сам, глубоко его разобрать, и только потом
|
||||
писать код, повторяя его как можно точнее»*.
|
||||
|
||||
И отдельно — **глубокий разбор картинки**: не «красиво», а перечислить composition,
|
||||
свет, глубину, материалы, где что стоит, что перекрывает что.
|
||||
|
||||
**Почему это наше главное лекарство.** Владелец **не умеет объяснять словами, что не
|
||||
так, — он умеет выбрать из показанного**. Четыре сборки отклонены как раз потому,
|
||||
что мы показывали ему готовый код вместо образа.
|
||||
|
||||
**Наш порядок:**
|
||||
|
||||
1. нарисовать образ главы картинкой;
|
||||
2. разобрать её письменно по пунктам: что где, какой свет, какая глубина;
|
||||
3. показать владельцу и **дать выбрать**;
|
||||
4. только после выбора — строить сцену, повторяя выбранное.
|
||||
|
||||
---
|
||||
|
||||
## Звено 9 · По отдельному образу на каждую главу
|
||||
|
||||
**Скил:** `imagegen-frontend-web` — начинается с правила, набранного заглавными:
|
||||
|
||||
> **CRITICAL OUTPUT RULE — generate ONE separate horizontal image FOR EVERY section.
|
||||
> A landing page with 8 sections produces 8 images. Never compress multiple sections
|
||||
> into one image.**
|
||||
|
||||
Плюс раздел 6 «Creativity Escalation» — если вариант вышел обычным, поднимать
|
||||
смелость и делать заново, а не защищать сделанное.
|
||||
|
||||
**Наш перевод.** Восемь глав — **восемь отдельных образов**, каждый в ширину.
|
||||
Один общий «мудборд» не годится: он позволяет не решить, что именно в главе 5.
|
||||
|
||||
Именно отсутствие этого шага оставляло панели пустыми.
|
||||
|
||||
Технически: образы делает `seedream-v5-pro` (16,88 ₽ за штуку, 90 секунд),
|
||||
оживляет `ltx-2` (60 ₽ за 6 секунд). Подробности — `03`.
|
||||
|
||||
---
|
||||
|
||||
## Звено 10 · Знак и фирменный мир
|
||||
|
||||
**Скил:** `brandkit` — разделы «Brand Strategy First», «Logo Concept Methods»,
|
||||
«Board Composition DNA».
|
||||
|
||||
**Чему учит.** Знак не рисуется «красиво», он строится одним из способов:
|
||||
монограмма со смыслом, действие продукта, слияние метафор, отрицательное
|
||||
пространство, геометрия построения.
|
||||
|
||||
**Наш перевод.** Знак «ЛИДЕРРА» уже существует — узлы и линии на рекламной картинке,
|
||||
и это ровно метод «действие продукта»: связи между контактами. **Не переизобретать.**
|
||||
|
||||
Скил нужен, только если знак придётся дорисовать до объёмного — тогда брать оттуда
|
||||
логику построения, а не новые идеи.
|
||||
|
||||
---
|
||||
|
||||
## Звено 11 · Записать найденный вкус системой
|
||||
|
||||
**Скил:** `stitch-skill` — форма записи `DESIGN.md`: атмосфера, палитра и роли,
|
||||
типографика, стилизация составных частей, принципы раскладки, движение,
|
||||
**запрещённые приёмы**.
|
||||
|
||||
**Чему учит.** Пока вкус живёт в голове, он теряется между главами и между сменами.
|
||||
Записанный — держит все восемь глав в одном языке.
|
||||
|
||||
**Наш перевод.** После того как владелец выбрал образы, свести решённое в один файл
|
||||
`dizayn-sistema.md`:
|
||||
|
||||
1. Атмосфера мира одной фразой.
|
||||
2. Палитра и роль каждого цвета (уже есть, §5).
|
||||
3. Типографика: кегли, где объёмное, где моноширинное.
|
||||
4. Материалы: чем сделан металл, чем стекло, чем свечение — с числами.
|
||||
5. Движение камеры: кривые, инерция, скорости слоёв.
|
||||
6. **Запрещённые приёмы** — свой список из звена 2.
|
||||
|
||||
Дальше **каждая глава сверяется с этим файлом**, а не с вдохновением.
|
||||
|
||||
---
|
||||
|
||||
## Звено 12 · Крайности как словарь
|
||||
|
||||
**Скилы:** `minimalist-skill` и `brutalist-skill` — брать **выборочно**.
|
||||
|
||||
- **`brutalist-skill`** — жёсткие сетки, предельный контраст кеглей, утилитарный
|
||||
цвет, терминальная эстетика. **Наш мелкий моноширинный слой накладок — ровно
|
||||
оттуда.** В эталоне подписи `-> WEBSITES`, `-> INSTALLATIONS` набраны именно так.
|
||||
- **`minimalist-skill`** — тёплая сдержанность, отказ от градиентов и теней.
|
||||
Целиком нам не годится (это противоположность эталону), но полезно для **тихих
|
||||
глав**, где нужен контраст с плотными.
|
||||
|
||||
**Не брать:** `redesign-skill` — мы начинаем с чистого листа, переделывать нечего.
|
||||
|
||||
---
|
||||
|
||||
## §13 · Таблица перевода — плоский веб → наш мир
|
||||
|
||||
Читать после каждого звена. Левый столбец — то, чему учат скилы. Правый — во что это
|
||||
превращается у нас.
|
||||
|
||||
| Понятие плоского веба | Наш эквивалент |
|
||||
|---|---|
|
||||
| Макро-пустота, `py-40` | Участок пути камеры, где нет ничего, 1,5–2 секунды полёта |
|
||||
| Секция | Глава мира — отрезок пути камеры |
|
||||
| Первый экран (hero) | Место, куда камера прилетает первым; буквы стоят в пространстве |
|
||||
| Карточка | Наклонённая стеклянная плита с образом внутри |
|
||||
| Двойной кант (Double-Bezel) | Стеклянная плита в металлической оправе, с фаской |
|
||||
| Сетка бенто | ❌ нет эквивалента, не переводится |
|
||||
| Липкая шапка | ❌ нет. Только пилюля-накладка в углу |
|
||||
| Подвал | Последняя глава мира: капли падают в кабинет |
|
||||
| Появление при прокрутке | Предмет входит в поле зрения камеры |
|
||||
| Наведение мышью | ❌ на телефоне его нет; заменяется близостью камеры |
|
||||
| Тень | Затенение объёмом и туманом по глубине |
|
||||
| Рамка в 1 пиксель | Светящаяся кромка плиты |
|
||||
| Иерархия кеглей | Иерархия **размера в пространстве и расстояния до камеры** |
|
||||
| Тёмная тема | ❌ мир один, всегда тёмный |
|
||||
| Прокрутка страницы | Движение камеры по пути |
|
||||
| Разделитель между блоками | Пустота и смена света |
|
||||
|
||||
---
|
||||
|
||||
## §14 · Проверка, что цепочка пройдена
|
||||
|
||||
Перед первой строкой кода честно ответить:
|
||||
|
||||
- [ ] Строка направления произнесена вслух и записана?
|
||||
- [ ] Три регулятора поставлены (9 / 10 / 2) и понято, что они означают?
|
||||
- [ ] Свой список признаков дешёвого составлен — для трёхмерного мира, не для веба?
|
||||
- [ ] Восемь глав сведены в таблицу по восьми осям, соседние расходятся ≥ 5?
|
||||
- [ ] Заголовки — не длиннее двух строк?
|
||||
- [ ] Понятно, откуда в каждой главе исходит свет?
|
||||
- [ ] Ритм неравномерный, между главами есть пустота?
|
||||
- [ ] Камера имеет массу и не читает событие прокрутки?
|
||||
- [ ] Нарисованы **восемь отдельных образов**, по одному на главу?
|
||||
- [ ] Владелец **выбрал** из показанного?
|
||||
- [ ] `dizayn-sistema.md` написан и все главы сверяются с ним?
|
||||
- [ ] Каждое найденное решение прошло три вопроса из мерила: **дорого?
|
||||
технологично? элегантно?**
|
||||
|
||||
Если хоть один пункт не отмечен — код писать рано.
|
||||
|
||||
---
|
||||
|
||||
## §15 · Последняя проверка перед показом владельцу
|
||||
|
||||
Не «всё ли сделано по списку», а один честный взгляд:
|
||||
|
||||
> **Человек, увидевший это впервые, скажет «ого» — или «аккуратно»?**
|
||||
|
||||
«Аккуратно» нам не нужно. Если работа вызывает «аккуратно», значит где-то в цепочке
|
||||
взяли первый вариант вместо найденного. Возвращаться к звену 3 и ломать однообразие
|
||||
заново.
|
||||
@@ -0,0 +1,264 @@
|
||||
# 05 · Как писать промты субагентам — правила и примеры
|
||||
|
||||
Правила выведены из разбора четырёх отклонённых сборок и из двух кругов рецензий
|
||||
у GPT-5.6 Sol и Perplexity (сырые ответы — `docs/research/2026-08-06-lending-*`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Главная опасность: подмена задачи знакомым образцом
|
||||
|
||||
Когда модели говорят «сделай лендинг», у неё внутри срабатывает готовый образец
|
||||
«рекламная страница»: шапка → первый экран → блок преимуществ → отзывы → цены →
|
||||
подвал. Дальше она делает **этот образец**, а всё, что вы просили сверх, навешивает
|
||||
сверху украшением.
|
||||
|
||||
Именно так родилась четвёртая сборка: трёхмерный мир стал **подложкой** для обычной
|
||||
страницы. Формально всё требования выполнены, по сути — не то.
|
||||
|
||||
**Вывод: задачу надо ставить так, чтобы знакомый образец не подходил вовсе.**
|
||||
Не «лендинг с трёхмерной сценой», а «мир, по которому летит камера; страницы нет».
|
||||
|
||||
## 2. 🔴 Запреты работают ПРОТИВ вас
|
||||
|
||||
Фраза «не делай шапку» **повышает** вероятность шапки. Причина простая: чтобы
|
||||
выполнить запрет, модель обязана сначала подумать о шапке — и та оказывается в
|
||||
рабочей памяти как готовый образец.
|
||||
|
||||
Чем сильнее образец давит по смыслу («это же лендинг»), тем вероятнее прорыв запрета.
|
||||
|
||||
**Что делать вместо запретов:**
|
||||
|
||||
| Плохо | Хорошо |
|
||||
|---|---|
|
||||
| «Не делай шапку и меню» | «Постоянных накладок ровно две: пилюля сверху справа и список снизу слева. Больше плоских элементов на экране нет» |
|
||||
| «Не используй карточки» | «Содержание живёт на наклонённых плитах внутри сцены, камера пролетает между ними» |
|
||||
| «Не делай обычную страницу» | «Прокрутка двигает камеру по миру. Документа, который можно прокрутить, не существует» |
|
||||
|
||||
**Правило: описывай то, что ДОЛЖНО быть, с такой точностью, чтобы для нежелательного
|
||||
не осталось места.**
|
||||
|
||||
## 3. Что работает — по убыванию силы
|
||||
|
||||
Проверено на нашем же случае.
|
||||
|
||||
| # | Приём | Сила |
|
||||
|---|---|---|
|
||||
| 1 | **Приёмка, которую исполнитель запускает командой и видит красное** | ★★★★★ |
|
||||
| 2 | **Таблица «вместо Х — Y»**: рядом, в одну строку, неверное и верное | ★★★★☆ |
|
||||
| 3 | **Полный образец нежелательного результата** с разбором, чем он плох | ★★★☆☆ |
|
||||
| 4 | **Картинки-образцы** вместо описаний словами | ★★★☆☆ |
|
||||
| 5 | Запрет словаря («слово „секция“ не употреблять») | ★☆☆☆☆ вредно |
|
||||
|
||||
### 1 — приёмка сильнее всех слов
|
||||
|
||||
Пока «правильно» — это мнение, исполнитель будет считать свою работу правильной.
|
||||
Как только «правильно» — это команда, которая печатает КРАСНО, спор заканчивается.
|
||||
|
||||
Приёмка должна проверять **не разметку, а картинку**: разметку легко обмануть
|
||||
невидимыми пустышками. Проверять надо распределение по глубине, перекрытие
|
||||
предметов, разбег скоростей при движении камеры, долю площади экрана под текстом.
|
||||
|
||||
🔑 **Приёмку пишет дирижёр и хранит ВНЕ рабочей папки исполнителя.**
|
||||
Иначе исполнитель её «поправит».
|
||||
|
||||
### 2 — таблица «вместо Х — Y»
|
||||
|
||||
Работает потому, что даёт замену, а не пустоту.
|
||||
|
||||
```text
|
||||
| Соблазн | Что делаем вместо |
|
||||
|----------------------------------|-------------------------------------------------|
|
||||
| Шапка с логотипом и меню | Пилюля 180×36 в правом верхнем углу, две ссылки |
|
||||
| Первый экран с заголовком и кнопкой | Буквы стоят в пространстве, камера летит на них |
|
||||
| Блок «Преимущества» из шести плиток | Шесть плит на разной глубине, камера идёт между |
|
||||
| Подвал с контактами | Последняя глава мира: капли падают в кабинет |
|
||||
```
|
||||
|
||||
## 4. Каркас промта субагенту
|
||||
|
||||
Порядок частей важен: самое главное — в начале и в конце, там оно держится крепче.
|
||||
|
||||
```text
|
||||
1. РОЛЬ и ЧЕМ КОНЧАЕТСЯ РАБОТА
|
||||
Два исхода: «готово» и «упёрся». Оба описать, чтобы не было третьего —
|
||||
«сделал вид, что готово».
|
||||
|
||||
2. ЧТО СТРОИМ — одной фразой, без слова «лендинг»
|
||||
|
||||
3. ИСТОЧНИКИ ИСТИНЫ — перечислить поимённо, с путями.
|
||||
Всё, чего нет в списке, брать запрещено.
|
||||
|
||||
4. ТАБЛИЦА «вместо Х — Y» + неизменяемые правила устройства
|
||||
|
||||
5. РАЗБОР ЭТАЛОНА по секундам, с путями к кадрам
|
||||
|
||||
6. ГЛАВЫ: список, что в каждой, какой образ
|
||||
|
||||
7. ЧТО ДОКАЗАНО РАБОТАЮЩИМ (технические факты) и чем пользоваться
|
||||
|
||||
8. ЖЁСТКИЕ УСЛОВИЯ: боевой сервер, личные данные, палитра, шрифты
|
||||
|
||||
9. САМОСТОЯТЕЛЬНОСТЬ: таблица умолчаний на мелкие развилки,
|
||||
определение «настоящей стены», порядок упрощения при нехватке времени
|
||||
|
||||
10. ПОРЯДОК РАБОТЫ по шагам
|
||||
|
||||
11. ЧЕМ ДОКАЗЫВАЕТСЯ ГОТОВНОСТЬ — команды приёмки
|
||||
|
||||
12. ЧЕГО НЕ ОБЕЩАТЬ
|
||||
|
||||
13. ПЕРВОЕ ДЕЙСТВИЕ — одно, конкретное
|
||||
```
|
||||
|
||||
## 5. Обязательные части — почему каждая нужна
|
||||
|
||||
### Два исхода вместо одного
|
||||
|
||||
```text
|
||||
Работа кончается одним из двух:
|
||||
· УСПЕХ — приёмка напечатала ЗЕЛЁНО, снимки приложены.
|
||||
· ЧЕСТНЫЙ ВЫХОД — уперся в стену; описал, во что именно, что пробовал,
|
||||
и оставил рабочее место в состоянии, из которого можно продолжить.
|
||||
Третьего исхода — «отчитаться готовым на неготовом» — нет.
|
||||
```
|
||||
|
||||
Без этого исполнитель, у которого кончается время, отчитывается «готово».
|
||||
|
||||
### Таблица умолчаний
|
||||
|
||||
Без неё субагент останавливается на каждой мелочи и спрашивает. С ней — идёт.
|
||||
|
||||
```text
|
||||
| Развилка | Умолчание, без вопроса |
|
||||
|---------------------------------|-------------------------------|
|
||||
| Скорость полёта камеры | 1 глава ≈ 1,5 экрана прокрутки|
|
||||
| Сколько точек в облаке | 25 000, на телефоне 8 000 |
|
||||
| Что при отказе видео | Первый кадр картинкой |
|
||||
| Не хватает времени на главу | Резать 7-ю и 6-ю, не 1-ю |
|
||||
```
|
||||
|
||||
### Определение «настоящей стены»
|
||||
|
||||
```text
|
||||
Стена — это когда:
|
||||
· нет доступа или ключа, и добыть его нельзя;
|
||||
· возможность физически отсутствует (проверено замером, не рассуждением);
|
||||
· требуется решение владельца о деньгах или о боевом сервере.
|
||||
Не стена: «сложно», «долго», «некрасиво вышло», «не с первого раза».
|
||||
```
|
||||
|
||||
## 6. Правила, снятые с наших грабель
|
||||
|
||||
1. **Один субагент — одна глава или один узел.** Свежий контекст на каждую задачу.
|
||||
2. **Git-задачи — только Sonnet или Opus, никогда Haiku.** Было: Haiku-субагенты
|
||||
угнали ветку соседней смены.
|
||||
3. **Перед каждым запуском** — проверка `git status`; **после** — проверка, что
|
||||
субагент не затащил в индекс чужое.
|
||||
4. **Не давать субагенту править файлы через Bash.** Только Edit/Write: файл,
|
||||
записанный из-под Bash, может молча исчезнуть, а `cp` отчитается успехом.
|
||||
5. **Требовать снимок экрана как доказательство**, а не описание словами.
|
||||
6. **Проверять доказательство на подделку:** зелёный сторож, который никогда не был
|
||||
красным, ничего не охраняет. Подсунуть заведомую ошибку и убедиться, что
|
||||
приёмка её ловит.
|
||||
|
||||
## 7. Пример хорошего куска промта
|
||||
|
||||
```text
|
||||
### Глава 3 — «Уникальные контакты без дублей»
|
||||
|
||||
ЧТО ВИДИТ ЧЕЛОВЕК
|
||||
Камера идёт вдоль потока одинаковых бирюзовых точек. Поток входит в узкое
|
||||
кольцо. За кольцом точек становится заметно меньше, и они разного размера:
|
||||
двойники отсеялись. Отсеянные уходят вниз и гаснут.
|
||||
|
||||
ГДЕ ЭТО В ПРОСТРАНСТВЕ
|
||||
Глава 3 занимает участок пути камеры с 34 по 48 условную единицу.
|
||||
Кольцо стоит на 41-й, диаметр 6 единиц, наклон 12° к пути камеры.
|
||||
|
||||
ЧТО НАПИСАНО
|
||||
Одна строка объёмными буквами, слева от кольца, читается 2,5 секунды:
|
||||
«Уникальные контакты. Без дублей.»
|
||||
|
||||
ЧЕМ СДЕЛАНО
|
||||
· поток — облако точек, свой код;
|
||||
· кольцо — тор, зеркальный материал + карта окружения;
|
||||
· отсев — тот же массив точек, у отсеянных гасится яркость по времени.
|
||||
|
||||
ЧЕМ ПРОВЕРЯЕТСЯ
|
||||
· на снимке 41-й единицы кольцо перекрывает часть потока (перекрытие 8–20 %);
|
||||
· при сдвиге камеры на 2 единицы ближние точки уезжают ≥ 12 пикселей,
|
||||
дальние ≤ 4 — то есть глубина настоящая, а не нарисованная;
|
||||
· доля площади экрана под плоским текстом ≤ 1 %.
|
||||
```
|
||||
|
||||
Здесь есть всё: что видно человеку, где это стоит, что написано, чем сделано,
|
||||
и **чем доказывается**. Ни одного запрета.
|
||||
|
||||
## 8. Пример ПЛОХОГО промта — так мы и получили четыре отказа
|
||||
|
||||
```text
|
||||
Сделай современный лендинг для Лидерры на three.js. Тёмный фон, бирюзовые
|
||||
акценты, красиво, как на видео. Не делай обычный лендинг, сделай что-то
|
||||
необычное с трёхмерной графикой. Восемь секций: главный экран, преимущества,
|
||||
каналы, цены, отзывы, вопросы, контакты. Не используй шаблонные решения.
|
||||
```
|
||||
|
||||
Чем плох:
|
||||
|
||||
- слово **«секции»** прямо приказывает построить обычную страницу;
|
||||
- «красиво, как на видео» — не проверяется ничем;
|
||||
- «не делай обычный лендинг» — запрет, который подсказывает обычный лендинг;
|
||||
- перечислены **разделы документа**, а не места в пространстве;
|
||||
- нет ни одной проверяемой цифры;
|
||||
- нет источников истины — исполнитель пойдёт брать из старого лендинга.
|
||||
|
||||
## 9. Кому что поручать
|
||||
|
||||
Роль контроллера — **дирижёр**. Код лучше не писать самому, а раздавать.
|
||||
|
||||
| Исполнитель | Что поручать |
|
||||
|---|---|
|
||||
| **Модель Fable 5** | Художественное направление: путь камеры, чем главы отличаются друг от друга, что именно в каждой панели. Проверяет глазами, кода не пишет |
|
||||
| **Субагент на Opus** | Сцена и код. Один субагент — одна глава или один узел |
|
||||
| **`seedream-v5-pro` + `ltx-2`** | Образы глав и их оживление |
|
||||
| **`tripo-3d` + `rodin`** | Предметы для построенной комнаты |
|
||||
| **Контроллер (дирижёр)** | Приёмка, замеры, деньги, разговор с владельцем |
|
||||
|
||||
🔴 **Приёмку пишет и запускает дирижёр, а не исполнитель.** Исполнитель, проверяющий
|
||||
сам себя, четыре раза отчитывался «готово» на неготовом.
|
||||
|
||||
Служебные скилы, помогающие дирижировать (вкуса они не формируют, поэтому их нет
|
||||
в цепочке `04`):
|
||||
|
||||
| Скил | Зачем |
|
||||
|---|---|
|
||||
| `superpowers` | Общая дисциплина работы. По правилу проекта инвокируется **первым**, если задача под него попадает |
|
||||
| `subagent-driven-development` | Свежий субагент на задачу, двухступенчатая проверка, git-безопасность |
|
||||
| `output-skill` | Запрещает обрывать длинный код заглушками вида «остальное аналогично». Сцена на three.js — тысячи строк за заход, без этого работа умирает на середине |
|
||||
| `frontend-design` (плагин Anthropic) | Штатный решатель по внешнему виду. ⚠️ Всё, что он предлагает на React / Tailwind / shadcn, — срезать |
|
||||
|
||||
## 10. Чужие работы: смотреть можно, код брать нельзя
|
||||
|
||||
Смотреть и разбирать глазами: **Active Theory** (наш эталон), **Lusion**,
|
||||
**Codrops**, официальные примеры three.js.
|
||||
|
||||
🔴 **Код копировать нельзя — сайт коммерческий:**
|
||||
|
||||
| Источник | Можно ли брать код |
|
||||
|---|---|
|
||||
| Официальные примеры three.js | ✅ да, MIT |
|
||||
| `drei` / React-Three-Fiber | ✅ да, MIT |
|
||||
| Theatre.js | ✅ да |
|
||||
| WalkerZyy/Lusion, ykob/sketch-threejs | ❌ нет лицензии |
|
||||
| Курс Three.js Journey | ❌ платный, код чужой |
|
||||
| Демо Codrops с пометкой non-commercial | ❌ нельзя |
|
||||
|
||||
Повторять **устройство**, увиденное глазами, — можно всегда. Копировать файлы — нет.
|
||||
|
||||
## 11. Как принимать работу
|
||||
|
||||
1. Запустить приёмку **своей** командой, не поверив на слово.
|
||||
2. Посмотреть снимки **глазами**. Отчёт «8 из 8 ДА» и картинка могут расходиться —
|
||||
у нас так и было: отчёт зелёный, а стекло чёрное и буквы вверх ногами.
|
||||
3. Задать один вопрос: **«это мир, по которому летит камера, или страница с
|
||||
трёхмерной подложкой?»** Если второе — переделка, сколько бы галочек ни стояло.
|
||||
@@ -0,0 +1,276 @@
|
||||
# 06 · Техническая база: что доказано работающим
|
||||
|
||||
**Каждая строка проверена запуском 06.08.2026.** Ничего не выведено рассуждением.
|
||||
Где не проверено — так и написано.
|
||||
|
||||
Рабочая проверка целиком лежит в `landing-3d/` (в git, кроме `node_modules`).
|
||||
Открывается: `npx vite --port 5233 --strictPort --host 127.0.0.1`, затем
|
||||
`http://127.0.0.1:5233/resursy.html`.
|
||||
|
||||
Снимки-доказательства:
|
||||
`docs/superpowers/prototypes/2026-08-06-kartina-resursov.png`,
|
||||
`…-hrom-ozhil.png`, `…-steklo-tri-varianta.png`.
|
||||
|
||||
---
|
||||
|
||||
## 1. 🔑 Зеркальный металл оживает только от карты окружения
|
||||
|
||||
**Это причина, по которой хром в сборках 2 и 3 выходил «белой лентой».**
|
||||
Отражать было нечего: карты окружения не было, и процедурный градиент её не заменяет —
|
||||
у него нет мелкой детали, а именно она и читается как отражение.
|
||||
|
||||
**Рецепт:**
|
||||
|
||||
```js
|
||||
const pmrem = new THREE.PMREMGenerator(renderer);
|
||||
pmrem.compileEquirectangularShader();
|
||||
new THREE.TextureLoader().load('/env/studio-white-teal.jpg', (tex) => {
|
||||
tex.mapping = THREE.EquirectangularReflectionMapping;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
scene.environment = pmrem.fromEquirectangular(tex).texture;
|
||||
tex.dispose();
|
||||
});
|
||||
const chrome = new THREE.MeshStandardMaterial({
|
||||
color: 0xffffff, metalness: 1, roughness: 0.045, envMapIntensity: 1.6,
|
||||
});
|
||||
```
|
||||
|
||||
**Какая карта нужна.** Проверено на двух:
|
||||
|
||||
| Карта | Что вышло |
|
||||
|---|---|
|
||||
| Только бирюзовые панели | Хром ожил, но вся сцена ушла в зелень — читается как крашеный пластик |
|
||||
| **Крупные белые софтбоксы + бирюзовые панели + синяя + золотые точки на чёрном** | Правильно: белые дают горячие блики, из-за которых металл читается металлом, бирюза и золото красят |
|
||||
|
||||
Готовые карты лежат в `landing-3d/public/env/`.
|
||||
Промт, которым сделана правильная, — в конце этого файла.
|
||||
|
||||
## 2. 🔴 Настоящее преломление (`transmission`) НЕ РИСУЕТСЯ
|
||||
|
||||
Три варианта стекла поставлены рядом в одной сцене:
|
||||
|
||||
| Вариант | Итог |
|
||||
|---|---|
|
||||
| `transmission: 1, thickness: 1.6, ior: 1.52` | **Чёрный силуэт** |
|
||||
| `transmission: 0.92, thickness: 0.6, ior: 1.35` | **Чёрный силуэт** |
|
||||
| `transparent + opacity + envMapIntensity + clearcoat + iridescence` | **Работает** |
|
||||
|
||||
**Дело не в машине.** Замер показал: `WebGL2 да`, `EXT_color_buffer_float да`,
|
||||
`OES_texture_float_linear да`. Второго прохода сцены просто не запускается:
|
||||
после добавления трёх предметов вызовов отрисовки стало 15 → 18, то есть ровно на
|
||||
три, а не вдвое.
|
||||
|
||||
**Рабочий рецепт стекла:**
|
||||
|
||||
```js
|
||||
const steklo = new THREE.MeshPhysicalMaterial({
|
||||
color: 0xbfe9dd, metalness: 0, roughness: 0.05,
|
||||
envMapIntensity: 2.2,
|
||||
transparent: true, opacity: 0.42, side: THREE.DoubleSide,
|
||||
iridescence: 0.7, iridescenceIOR: 1.4,
|
||||
clearcoat: 1, clearcoatRoughness: 0.03,
|
||||
});
|
||||
```
|
||||
|
||||
Сквозь него видно то, что позади. Он **вдвое дешевле** по скорости — второго прохода
|
||||
сцены нет. Для обязательного полного полёта на телефоне это плюс, а не уступка.
|
||||
|
||||
⚠️ Открытый вопрос: работает ли `transmission` на настоящей видеокарте. Нам не нужен.
|
||||
|
||||
## 3. Объёмные РУССКИЕ буквы — работают
|
||||
|
||||
Готового файла шрифта для three.js с кириллицей нет, его надо изготовить.
|
||||
|
||||
**Шаг 1. Взять настоящий TTF.**
|
||||
🔴 Google Fonts по ссылке `css2` со старой подписью браузера отдаёт **EOT, не TTF**
|
||||
(файл начинается на `44 55 05 00`, а не на `00 01 00 00`). Брать из репозитория:
|
||||
|
||||
```bash
|
||||
curl -sL "https://raw.githubusercontent.com/google/fonts/main/ofl/unbounded/Unbounded%5Bwght%5D.ttf" -o Unbounded-var.ttf
|
||||
```
|
||||
|
||||
**Шаг 2. Перевести в формат three.js.** Готовый переводчик —
|
||||
`landing-3d/tools/font2typeface.mjs`. Два подводных камня, оба стоили заходов:
|
||||
|
||||
- `opentype.loadSync()` устарел и возвращает пустоту — только
|
||||
`opentype.parse(fs.readFileSync(src).buffer)`;
|
||||
- 🔴 **opentype рисует с осью Y вниз, three.js ждёт вверх.** Без переворота буквы
|
||||
встают вверх ногами, и это не сразу заметно: «НАХОДИТЕ» превращается в почти
|
||||
похожее «HVXOДИТЕ». Все координаты Y умножать на −1.
|
||||
|
||||
122 знака (русские заглавные и строчные, латиница, цифры, знаки) → файл **45 КБ**.
|
||||
|
||||
**Шаг 3. Использовать:**
|
||||
|
||||
```js
|
||||
new FontLoader().load('/fonts/unbounded.typeface.json', (font) => {
|
||||
const g = new TextGeometry('НАХОДИТЕ\nКЛИЕНТОВ', {
|
||||
font, size: 0.92, depth: 0.34, curveSegments: 6,
|
||||
bevelEnabled: true, bevelThickness: 0.035, bevelSize: 0.025, bevelSegments: 3,
|
||||
});
|
||||
scene.add(new THREE.Mesh(g, material));
|
||||
});
|
||||
```
|
||||
|
||||
Материал букв — как в эталоне: **матовый, слегка металлический, НЕ зеркальный**
|
||||
(`metalness: 0.35, roughness: 0.28`).
|
||||
|
||||
## 4. Видео внутри стеклянной панели — работает
|
||||
|
||||
Это то, чего не хватало всем четырём сборкам.
|
||||
|
||||
```html
|
||||
<video id="v" src="/video/voronka.mp4" muted loop playsinline crossorigin="anonymous"></video>
|
||||
```
|
||||
|
||||
```js
|
||||
const vtex = new THREE.VideoTexture(document.getElementById('v'));
|
||||
vtex.colorSpace = THREE.SRGBColorSpace;
|
||||
video.play(); // без звука проигрывание разрешено везде
|
||||
const mat = new THREE.MeshPhysicalMaterial({
|
||||
map: vtex, emissive: 0xffffff, emissiveMap: vtex, emissiveIntensity: 0.9,
|
||||
color: 0x223a34, metalness: 0.1, roughness: 0.12,
|
||||
transparent: true, opacity: 0.96,
|
||||
});
|
||||
```
|
||||
|
||||
**Обязательно:** `muted`, `loop`, `playsinline`. Без `muted` браузер не даст играть
|
||||
без нажатия. Свечение (`emissiveMap`) нужно, чтобы видео читалось в тёмной сцене.
|
||||
|
||||
⚠️ Открытый вопрос: 6-секундный ролик **не зациклен**, на стыке будет рывок.
|
||||
Лечится проигрыванием туда-обратно или наплывом. Не пробовали.
|
||||
|
||||
### Панель со скруглёнными углами и ровной текстурой
|
||||
|
||||
`ExtrudeGeometry` по фигуре со скруглениями даёт кривые координаты текстуры —
|
||||
видео растекается по фаске. Пересчитывать вручную:
|
||||
|
||||
```js
|
||||
g.computeBoundingBox();
|
||||
const bb = g.boundingBox, sx = bb.max.x - bb.min.x, sy = bb.max.y - bb.min.y;
|
||||
const pos = g.attributes.position, uv = g.attributes.uv;
|
||||
for (let i = 0; i < pos.count; i++)
|
||||
uv.setXY(i, (pos.getX(i) - bb.min.x) / sx, (pos.getY(i) - bb.min.y) / sy);
|
||||
uv.needsUpdate = true;
|
||||
```
|
||||
|
||||
## 5. Плоская картинка + карта глубины = объём
|
||||
|
||||
Дешёвая замена видео: `midas` даёт карту глубины за копейки, дальше сдвиг в шейдере.
|
||||
|
||||
```glsl
|
||||
float d = texture2D(tDep, vUv).r;
|
||||
vec2 uv = vUv + uShift * (d - 0.5) * uAmp * 8.0;
|
||||
gl_FragColor = vec4(texture2D(tCol, uv).rgb, 1.0);
|
||||
```
|
||||
|
||||
`uShift` привязывается к движению камеры. Работает, но слабее видео.
|
||||
|
||||
## 6. Готовые объёмные предметы от моделей — работают
|
||||
|
||||
`tripo-3d` и `rodin` отдают GLB, `GLTFLoader` их читает без плясок. Ставить так:
|
||||
|
||||
```js
|
||||
const b = new THREE.Box3().setFromObject(o), s = b.getSize(new THREE.Vector3());
|
||||
o.scale.setScalar(nuzhnyRazmer / Math.max(s.x, s.y, s.z));
|
||||
```
|
||||
|
||||
⚠️ **Материал у них свой и обычно негодный** — перекрывать своим обходом
|
||||
`o.traverse(n => { if (n.isMesh) n.material = nash; })`.
|
||||
|
||||
⚠️ **`rodin` отдал 7,4 МБ.** Это много. `tripo-3d` с `face_limit` — 1,1 МБ.
|
||||
|
||||
## 7. Облако точек — свой код, бесплатно, самое выгодное
|
||||
|
||||
26 000 точек — **один вызов отрисовки**. Кораллы эталона делаются именно так.
|
||||
|
||||
Устройство: несколько центров-кустов, вокруг каждого степенное распределение
|
||||
(`Math.pow(Math.random(), 2.2) * R`) — оно даёт плотное ядро и редкую периферию,
|
||||
как у настоящего коралла. Размер точки — по расстоянию:
|
||||
`gl_PointSize = aSize * 620.0 / -mv.z`. Смешивание — `AdditiveBlending`,
|
||||
`depthWrite: false`. Полный код — `landing-3d/src/resursy.js`.
|
||||
|
||||
## 8. Замеры скорости
|
||||
|
||||
| Условие | Кадров/с | Вызовов | Треугольников |
|
||||
|---|---|---|---|
|
||||
| Стол 1600×900, всё вместе | 234 | 18 | 159 100 |
|
||||
| Телефонный размер 390×844, процессор /4 | 31 | 7 | 12 320 |
|
||||
|
||||
🔴 **Второй замер честным не считать:** за 18 секунд загрузилась лишь часть сцены
|
||||
(7 вызовов вместо 18). Что из него следует наверняка: **узкое место на слабом
|
||||
устройстве — загрузка, а не отрисовка**.
|
||||
|
||||
🔴 **Настоящую скорость меряет только настоящий телефон.** На рабочей машине
|
||||
видеокарты нет вовсе, всё считает процессор (SwiftShader, ANGLE/Vulkan).
|
||||
|
||||
## 9. Снимки экрана без видеокарты
|
||||
|
||||
```js
|
||||
chromium.launch({ args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader',
|
||||
'--enable-webgl', '--ignore-gpu-blocklist'] })
|
||||
```
|
||||
|
||||
🔴 **`--use-gl=swiftshader` ломает трёхмерность МОЛЧА** — контекст создаётся и сразу
|
||||
теряется, кадр белый, в консоли пусто. Датчик — `gl.isContextLost()`.
|
||||
|
||||
Замедление процессора для грубой замены телефону:
|
||||
|
||||
```js
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
|
||||
```
|
||||
|
||||
Готовый снимальщик — `landing-3d/tools/shot.mjs`.
|
||||
|
||||
## 10. Грабли, каждая стоила захода
|
||||
|
||||
- **`three` 0.180 разбит на два файла** — `three.module.js` тянет `three.core.js`.
|
||||
Копировать оба, иначе страница живая, а сцена пустая.
|
||||
- **`IcosahedronGeometry` не индексирована** (`geo.index === null`) — обход рёбер
|
||||
падает. Лечение `.toNonIndexed()` + дедупликация по округлённым координатам.
|
||||
- **Менять `material.transparent` каждый кадр нельзя** — движок пересобирает шейдеры,
|
||||
страница встаёт.
|
||||
- **`String.replace('маркер', код)` молча портит код** — `$&` в строке-замене особая
|
||||
последовательность. Только `.replace(m, () => код)`.
|
||||
- **`npm install --no-save` выпиливает ранее поставленное** — ставить одной командой.
|
||||
- **Правки файлов — только Edit/Write.** Записанный из-под Bash файл может молча
|
||||
исчезнуть, а `cp` при этом отчитается успехом.
|
||||
- **Фоновый процесс из `(cmd &)` не переживает вызов** — поднимать через штатный
|
||||
фоновый режим.
|
||||
- **`localhost` может не отвечать, когда `127.0.0.1` отвечает.** Обращаться по адресу.
|
||||
- Многострочные строки из PowerShell в Bash — только heredoc, не `@'…'@`.
|
||||
|
||||
## 11. Грабли коммита в этом репозитории
|
||||
|
||||
- **В репозитории работает соседняя смена.** Её файлы попадают в индекс при неудачном
|
||||
прогоне хуков — перед коммитом **всегда** сверять `git diff --cached --name-only`.
|
||||
- **`docs/observer/STATUS.md` не пре-стейджить** — сам лезет в индекс.
|
||||
- **Хук-исправитель разметки при неудаче откатывает файлы**, в том числе
|
||||
`cspell-words.txt` — терялись и свои слова, и чужие.
|
||||
- **`docs/superpowers/` не линтуется, `docs/research/` — линтуется.**
|
||||
Номера источников вида `[4][5]` markdown принимает за битые ссылки — экранировать
|
||||
открывающую скобку: `\[4]`.
|
||||
- После коммита всегда `git show --stat`.
|
||||
|
||||
## 12. Промт, которым сделана правильная карта окружения
|
||||
|
||||
```text
|
||||
Equirectangular 360-degree HDRI studio lighting environment, 2:1 seamless spherical
|
||||
panorama. Pitch-black void. Floating light sources of three kinds, scattered at
|
||||
different sizes and heights: (1) four or five large pure WHITE softbox rectangles,
|
||||
very bright, sharp-edged, the dominant light; (2) three medium glowing panels in
|
||||
bright teal-cyan and one in cool steel blue; (3) five small warm golden-orange point
|
||||
lights. Strong specular studio setup for photographing chrome and polished metal:
|
||||
crisp bright rectangles against absolute black, high dynamic range look, deep blacks
|
||||
between the lights. No horizon, no ground, no walls, no objects, no people, no text,
|
||||
no gradients across the whole frame.
|
||||
|
||||
negative_prompt: text, watermark, people, buildings, horizon line, landscape, sky,
|
||||
sun, room interior, furniture, seam, vignette, uniform green tint, purple, pink,
|
||||
magenta, red, muddy, foggy
|
||||
|
||||
width 2048, height 1024, translate_input false
|
||||
```
|
||||
|
||||
Модель `seedream-v5-pro`, 16,88 ₽, 90 секунд, вышло с первого раза.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 07 · Изоляция: с чем НЕ связываться
|
||||
|
||||
**Прямое требование владельца:** работа начинается с чистого листа. К сегодняшнему
|
||||
состоянию лендинга привязываться нельзя. Опора — только на **смысл и возможности
|
||||
портала** и на **эталон**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Правило одной строкой
|
||||
|
||||
> **Содержание** берётся из `01-produkt-chto-prodayom.md` и рекламной картинки.
|
||||
> **Устройство и вид** берутся из эталона (`02-etalon-razbor-po-kadram.md`).
|
||||
> **Больше ниоткуда.**
|
||||
|
||||
Если рука тянется открыть что-то, чего нет в этих двух источниках, — остановиться и
|
||||
спросить себя, зачем.
|
||||
|
||||
## 2. 🔴 Что открывать запрещено
|
||||
|
||||
| Что | Почему |
|
||||
|---|---|
|
||||
| **Боевой `liderra.ru` и его исходники** | Это старый лендинг. Открыл — привязался. Его вид, его сетка, его блоки — ровно то, от чего уходим |
|
||||
| **Четыре отклонённые сборки:** `docs/superpowers/prototypes/_landing-v6…v9-template.html` | Каждая — застывшая ошибка. Смотреть их значит наследовать её |
|
||||
| `docs/superpowers/prototypes/_probe.html`, `_shotter.html`, `_v3_template.html` | Остатки тех же попыток |
|
||||
| **`лендинг/TZ_landing_v1_0.md`** — старое ТЗ рекламного лендинга (v1.0 от 08.05.2026) | Написано под обычную страницу с секциями. Приостановлено. Прямо противоречит задаче |
|
||||
| `лендинг/landing_designer_handoff_v1_1.zip`, `README_для_дизайнера.md` | То же самое |
|
||||
| **Handoff Платона `liderra_v8_handoff/`** — компоненты и экраны | Это оформление **кабинета**, не лендинга. Оттуда берём **только палитру и шрифты**, ничего больше |
|
||||
| Любые «референсы конкурентов» из прошлых заходов | Не эталон |
|
||||
|
||||
## 3. Что брать МОЖНО и нужно
|
||||
|
||||
| Что | Зачем | Ограничение |
|
||||
|---|---|---|
|
||||
| `01-produkt-chto-prodayom.md` | Всё содержание | — |
|
||||
| `02-etalon-razbor-po-kadram.md` + папка `etalon/` | Устройство и вид | — |
|
||||
| `моя/лендинг/image-06-08-26-10-41.png` | Зрительный язык, цвета, главный образ | — |
|
||||
| `моя/лендинг/video-06-08-26-10-41.mov` | Эталон | — |
|
||||
| `03`, `04`, `06` этой сборки | Ресурсы, скилы, техника | — |
|
||||
| **Палитра и шрифты** из брендбука | Цвет и типографика | **Только они.** Компоненты, сетки, экраны — нет |
|
||||
| `db/schema.sql` | Уточнить факт о продукте (стадии, каналы) | Только как справка |
|
||||
| `app/resources/js/router/index.ts` | Список разделов кабинета | Только как справка |
|
||||
|
||||
## 4. Отдельно про снимки экрана портала
|
||||
|
||||
🔴 **Кусков нашего портала внутрь панелей — НЕТ.** Это отдельное и раньше данное
|
||||
решение владельца, оно **в силе** и не отменено разрешением пользоваться моделями:
|
||||
|
||||
> «куски нашего портала внутрь панелей — нет! можем брать только суть и функционал»
|
||||
|
||||
Содержание панелей **рисуется моделями** по смыслу главы, а не снимается с экрана
|
||||
кабинета. Планшет с дашбордом на рекламной картинке — это **образ**, нарисованный
|
||||
дизайнером, а не снимок; повторять надо образ, а не делать скриншот.
|
||||
|
||||
## 5. Что отменено и больше не действует
|
||||
|
||||
| Решение | Статус |
|
||||
|---|---|
|
||||
| **Р-К6** «картинок в сборке нет вообще» | ❌ **ОТМЕНЕНО** решением Р-К7 06.08 вечером |
|
||||
| Следствие Р-К6 «хрома и стекла не будет, мир матовый» | ❌ **ОТМЕНЕНО** |
|
||||
| Потолки веса 1,5 МБ первая загрузка / 3 МБ всё | ❌ **СНЯТЫ** — писались под запрет картинок |
|
||||
| Красная проверка приёмки на любой файл изображения | ❌ **СНЯТА** |
|
||||
|
||||
**Три документа, написанные до Р-К7** —
|
||||
`docs/superpowers/2026-08-06-PROMT-lending-subagent.md`,
|
||||
`…-PRIEMKA-lending-spec.md`, `…-KONTRAKT-fable-artdirection.md` —
|
||||
**устарели в части запретов**. Читать их можно ради устройства и приёмочных приёмов,
|
||||
но запреты на картинки, хром и стекло из них **не применять**.
|
||||
|
||||
## 6. Что остаётся в силе всегда
|
||||
|
||||
- **Показ только по `liderra.ru/new`.** Главную не трогать. Подмена — только после
|
||||
прямого «да» владельца.
|
||||
- **Полный трёхмерный полёт на телефоне обязателен.** Упрощённого «мобильного
|
||||
режима» с плоской вёрсткой нет.
|
||||
- **Форм заявки нет, юридических ссылок нет.** Риск принят владельцем.
|
||||
- **Счётчик Метрики заперт** на домен `liderra.ru` и путь `/`. Защиту не снимать.
|
||||
- **Никаких настоящих личных данных** в сцене.
|
||||
- 🔴 **Боевой `liderra.ru` — живые клиенты и деньги.** Контрольная сумма главной
|
||||
`5046a638f9e311f2c4b073721112b6b1`, проверять после любой работы:
|
||||
`curl -s https://liderra.ru/ | md5sum`.
|
||||
|
||||
## 7. Признаки, что изоляция нарушена
|
||||
|
||||
Остановиться и переделать, если в работе появилось хоть одно:
|
||||
|
||||
- слова **«секция», «блок», «карточка», «сетка», «шапка», «подвал»** в описании
|
||||
устройства;
|
||||
- плоские элементы занимают больше 1 % площади экрана, кроме двух накладок;
|
||||
- трёхмерная сцена оказалась **фоном** для прокручиваемого содержимого;
|
||||
- прокрутка двигает **документ**, а не камеру;
|
||||
- цвет или шрифт взят не из палитры;
|
||||
- в панели оказался снимок экрана портала.
|
||||
|
||||
## 8. Как проверить себя одним вопросом
|
||||
|
||||
> **Это мир, по которому летит камера, — или страница с трёхмерной подложкой?**
|
||||
|
||||
Если ответ второй, никакое количество выполненных пунктов не спасает. Именно так
|
||||
была отклонена четвёртая сборка со словами «ты по сути фон заменил».
|
||||
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 1001 KiB |
|
After Width: | Height: | Size: 820 KiB |
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-08-07T03:48:05.151Z
|
||||
Last updated: 2026-08-07T05:38:05.233Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -116,9 +116,9 @@ Episodes since last run: 542 / threshold: 10
|
||||
|
||||
| PID | Имя | CPU-время | Возраст |
|
||||
|---|---|---|---|
|
||||
| 11840 | Code | 15.88ч | NaNч |
|
||||
| 23856 | MsMpEng | 15.11ч | NaNч |
|
||||
| 4 | System | 8.87ч | NaNч |
|
||||
| 23856 | MsMpEng | 20.56ч | 0.0ч |
|
||||
| 11840 | Code | 16.69ч | 1328885.6ч |
|
||||
| 4 | System | 9.34ч | NaNч |
|
||||
|
||||
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# Картина ресурсов для лендинга — что проверено на практике
|
||||
|
||||
**Снято 06.08.2026 вечером.** Ничего не взято «по памяти» и не выведено рассуждением:
|
||||
каждая строка ниже либо видна на снимке, либо посчитана командой. Где не проверено —
|
||||
так и написано.
|
||||
|
||||
Доказательства-картинки:
|
||||
|
||||
- `docs/superpowers/prototypes/2026-08-06-hrom-ozhil.png` — хром с картой окружения;
|
||||
- `docs/superpowers/prototypes/2026-08-06-kartina-resursov.png` — все возможности на одном экране;
|
||||
- `docs/superpowers/prototypes/2026-08-06-steklo-tri-varianta.png` — три способа сделать стекло.
|
||||
|
||||
Рабочая папка проверок — `landing-3d/` (в git, кроме `node_modules` и `dist`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Главное: почему четыре сборки провалились
|
||||
|
||||
Разбор эталона по кадрам показал, что в нём **четыре разных типа сцены**, и зеркальный
|
||||
материал применён **скупо — на одном предмете** (кольцо возле заголовка). Основная
|
||||
красота держится на:
|
||||
|
||||
1. **видео, играющем внутри стеклянных панелей** — это половина хронометража ролика;
|
||||
2. **объёмных облаках точек** («кораллы») между панелями;
|
||||
3. **построенной комнате** с мокрым отражающим полом, висящей установкой и тросами.
|
||||
|
||||
Мы четыре раза делали ставку на хром и получали белую ленту, а панели оставляли
|
||||
пустыми — то есть повторяли самое дешёвое и пропускали самое дорогое.
|
||||
|
||||
**Причина «белой ленты» найдена и снята.** Зеркальному металлу нечего было отражать:
|
||||
карты окружения не было. С картой (снимок `hrom-ozhil.png`) на кольце появились
|
||||
бирюзовые блики и тёмные полосы. Проверено сравнением, а не на слово.
|
||||
|
||||
---
|
||||
|
||||
## 2. Что доказано работающим
|
||||
|
||||
| № | Возможность | Как доказано | Итог |
|
||||
|---|---|---|---|
|
||||
| 1 | **Карта окружения оживляет хром** | Сцена с кольцом, снимок | ✅ Работает |
|
||||
| 2 | **Стекло** | Три варианта рядом, снимок | ⚠️ См. §3 — работает только один способ из трёх |
|
||||
| 3 | **Объёмные русские буквы** | «НАХОДИТЕ КЛИЕНТОВ» вытянутыми буквами Unbounded | ✅ Работает |
|
||||
| 4 | **Видео внутри стеклянной панели** | Ролик-воронка играет на наклонённой панели | ✅ Работает |
|
||||
| 5 | **Плоская картинка + карта глубины = объём** | Сдвиг по карте глубины в шейдере | ✅ Работает |
|
||||
| 6 | **Готовый предмет от Tripo3D** | Установка с тросами и постаментом в сцене | ✅ Работает |
|
||||
| 7 | **Органика от Rodin** | Коралловый куст в сцене | ✅ Работает, но тяжёлый — см. §4 |
|
||||
| 8 | **Облако из 26 000 точек** | Свой код, без моделей | ✅ Работает, бесплатно |
|
||||
|
||||
Всё это — **на одном экране одновременно**: 18 вызовов отрисовки, 159 100 треугольников,
|
||||
6 текстур. Снимок `kartina-resursov.png`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Что НЕ работает и чем заменено
|
||||
|
||||
### `transmission` (настоящее преломление) не рисуется
|
||||
|
||||
Три варианта стекла бок о бок:
|
||||
|
||||
| Вариант | Итог |
|
||||
|---|---|
|
||||
| `transmission: 1, thickness: 1.6` | **Чёрный силуэт** |
|
||||
| `transmission: 0.92, thickness: 0.6` | **Чёрный силуэт** |
|
||||
| `transparent: true, opacity: 0.42` + сильное отражение окружения + `clearcoat` + `iridescence` | **Работает** — сквозь предмет видна решётка позади |
|
||||
|
||||
Дело **не в машине**: замер показал `WebGL2 да`, `плавающий буфер да`,
|
||||
`сглаживание его да`. Второй проход сцены просто не запускается — вызовов отрисовки
|
||||
после добавления трёх предметов стало 15 → 18, то есть ровно на три, а не вдвое.
|
||||
|
||||
**Решение: `transmission` не используем.** Рабочий рецепт стекла — третий вариант.
|
||||
Он ещё и вдвое дешевле по скорости (нет второго прохода сцены), что важно: полный
|
||||
трёхмерный полёт на телефоне обязателен.
|
||||
|
||||
### Старый `flux` не умеет 2:1
|
||||
|
||||
Жёсткий список разрешений без соотношения 2:1. Панорама делается на `seedream-v5-pro`
|
||||
(2048×1024 приняты с первого раза) или на `nano-banana-pro` (там соотношение задаётся
|
||||
списком, есть 21:9).
|
||||
|
||||
---
|
||||
|
||||
## 4. Модели: что, почём, сколько ждать — измерено
|
||||
|
||||
Счёт владельца на gen-api. Баланс на начало **2000,30 ₽**, на конец **1776,02 ₽**,
|
||||
израсходовано **224,28 ₽** на все пробы вместе.
|
||||
|
||||
| Модель | Что дала | Цена | Ждать | Замечание |
|
||||
|---|---|---|---|---|
|
||||
| `seedream-v5-pro` | Карта окружения 2048×1024 | **16,88 ₽** | 90 с | С первого раза, без правок |
|
||||
| `tripo-3d` | Установка с тросами, GLB | **50,00 ₽** | 163 с | 1,1 МБ — нормально |
|
||||
| `ltx-2` | Видео 6 с, 1920×1080, H.264 | **60,00 ₽** | 41 с | 16,9 МБ; после пережатия под веб **562 КБ** |
|
||||
| `rodin` | Коралловый куст, GLB | **~80 ₽** | 90 с | **7,4 МБ — тяжело**, нужно прореживать сетку |
|
||||
| `midas` | Карта глубины по картинке | копейки | 9 с | Самый дешёвый способ дать объём плоской картинке |
|
||||
|
||||
**Как узнавать поля любой модели** (это не описано публично, найдено в коде сайта):
|
||||
|
||||
```text
|
||||
GET https://api.gen-api.ru/api/network/info/parameters/<слуг> — какие поля просит
|
||||
GET https://api.gen-api.ru/api/network/category/<раздел>/networks — список моделей раздела
|
||||
```
|
||||
|
||||
Оба адреса открытые, ключ не нужен. Полный список моделей —
|
||||
`docs/superpowers/findings/2026-08-06-genapi-nastoyashchie-nazvaniya-modeley.md`.
|
||||
|
||||
**Больше не угадывать названия.** Отказ 404 означает только неверно угаданное имя.
|
||||
|
||||
---
|
||||
|
||||
## 5. Скорость
|
||||
|
||||
| Условие | Кадров в секунду | Вызовов | Треугольников |
|
||||
|---|---|---|---|
|
||||
| Рабочий стол 1600×900 | 234 | 18 | 159 100 |
|
||||
| Телефонный размер 390×844, процессор в 4 раза медленнее | 31 | 7 | 12 320 |
|
||||
|
||||
🔴 **Второй замер честным не считаю:** за 18 секунд успела загрузиться лишь часть
|
||||
сцены (7 вместо 18 вызовов), то есть 31 кадр в секунду получен на неполной картинке.
|
||||
Вывод, который из него всё же следует: **узкое место на слабом устройстве — не
|
||||
отрисовка, а загрузка**. Коралл на 7,4 МБ обязан похудеть.
|
||||
|
||||
**Настоящую скорость можно замерить только на настоящем телефоне.** Здесь видеокарты
|
||||
нет вовсе, всё считает процессор (SwiftShader).
|
||||
|
||||
---
|
||||
|
||||
## 6. Свои подчинённые
|
||||
|
||||
| Кто | Роль |
|
||||
|---|---|
|
||||
| `frontend-design` (Anthropic) | Решатель по интерфейсу и внешнему виду — штатный напарник |
|
||||
| `ui-ux-pro-max`, `design` | Дополнительные наборы по оформлению |
|
||||
| `taste-skill`, `minimalist-skill`, `brutalist-skill`, `redesign-skill`, `brandkit` | Готовые вкусовые наборы user-уровня |
|
||||
| `image-to-code-skill`, `stitch-skill`, `imagegen-frontend-web` | Из картинки в вёрстку |
|
||||
| Модель Fable 5 | Художественное направление (по отдельному контракту) |
|
||||
| Модели через aitunnel (GPT-5.6 Sol, Perplexity) | Разведка и рецензия |
|
||||
|
||||
---
|
||||
|
||||
## 7. Из чего теперь можно собрать лендинг
|
||||
|
||||
Каждая строка опирается на проверенное выше.
|
||||
|
||||
| Часть эталона | Чем делаем | Чем подтверждено |
|
||||
|---|---|---|
|
||||
| Пустота с частицами | Свой код, облако точек | §2 п.8 |
|
||||
| Объёмный заголовок | Unbounded → объёмные буквы | §2 п.3 |
|
||||
| Одно зеркальное кольцо рядом | Кольцо + карта окружения | §2 п.1 |
|
||||
| Стеклянные панели | Рецепт «полупрозрачность + отражение» | §3 |
|
||||
| **Содержимое панелей** | Видео от `ltx-2`, по ролику на главу | §2 п.4 |
|
||||
| Дешёвая замена видео | Картинка `seedream` + глубина `midas` | §2 п.5 |
|
||||
| «Кораллы» между панелями | Свой код + отдельные кусты от `rodin` | §2 п.7, 8 |
|
||||
| Построенная комната | Предметы от `tripo-3d` | §2 п.6 |
|
||||
|
||||
**Ориентировочная смета на восемь глав** (по измеренным ценам):
|
||||
|
||||
| Статья | Расчёт | Сумма |
|
||||
|---|---|---|
|
||||
| Видео в панели, 8 глав | 8 × 60 ₽ | 480 ₽ |
|
||||
| Карты окружения, 2 шт. | 2 × 16,88 ₽ | 34 ₽ |
|
||||
| Предметы для комнаты, 3 шт. | 3 × 50 ₽ | 150 ₽ |
|
||||
| Органика, 2 шт. | 2 × 80 ₽ | 160 ₽ |
|
||||
| Запас на переделки, ~40 % | | 330 ₽ |
|
||||
| **Итого** | | **~1150 ₽** |
|
||||
|
||||
На счету 1776 ₽ — хватает с запасом.
|
||||
|
||||
---
|
||||
|
||||
## 8. Что осталось непроверенным
|
||||
|
||||
1. **Скорость на настоящем телефоне.** Здесь замерить нельзя.
|
||||
2. **Насколько ровно ложится шов** у карты окружения по краям — на снимке края тёмные,
|
||||
но полного оборота камеры я не делал.
|
||||
3. **Как заставить видео идти без рывка на стыке** — 6-секундный ролик не зациклен.
|
||||
Лечится проигрыванием туда-обратно или наплывом; не пробовал.
|
||||
4. **Прореживание тяжёлых моделей** (7,4 МБ → сколько) — не пробовал.
|
||||
5. **Работает ли `transmission` на настоящей видеокарте.** Нам он не нужен, но знать
|
||||
стоит.
|
||||
6. **Цены остальных моделей** — таблица цен закрыта веб-сессией, каждая цена меряется
|
||||
отдельным запуском.
|
||||
@@ -0,0 +1,220 @@
|
||||
# ПРОМТ следующей смене — лендинг Лидерры, продолжение
|
||||
|
||||
**Составлен 06.08.2026 ~19:20.** Прежняя смена устала и начала срезать углы — владелец
|
||||
это заметил и остановил работу. Ниже только факты и состояние, без выводов прежней смены.
|
||||
|
||||
---
|
||||
|
||||
## 1. Последнее решение владельца
|
||||
|
||||
> **«так максимально к эталону делаем!»** (06.08, вечер)
|
||||
|
||||
Дано в ответ на вопрос, снимать ли решение Р-К6 (запрет картинок в сборке) теперь, когда
|
||||
появился доступ к генераторам картинок, трёхмерных моделей и видео.
|
||||
|
||||
**Что это значит на бумаге:** решение Р-К6 от 06.08 («картинок в сборке нет вообще,
|
||||
источников истины ровно три») этим указанием **отменяется или сильно меняется**.
|
||||
Точную границу владелец словами не задал — уточнить у него, если понадобится.
|
||||
|
||||
Три документа (промт субагенту, спецификация приёмки, контракт с Fable) писались **до**
|
||||
этого указания и содержат жёсткий запрет на любые файлы картинок и на хром со стеклом.
|
||||
**Они этому указанию противоречат и не переписаны.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Что уже сохранено в репозитории
|
||||
|
||||
Коммит **`285b955f7`** — 25 файлов, 9382 строки. Не отправлен на сервер.
|
||||
|
||||
| Что | Где |
|
||||
|---|---|
|
||||
| Промт субагенту, редакция 3 | `docs/superpowers/2026-08-06-PROMT-lending-subagent.md` |
|
||||
| Спецификация приёмки | `docs/superpowers/2026-08-06-PRIEMKA-lending-spec.md` |
|
||||
| Контракт с Fable 5 | `docs/superpowers/2026-08-06-KONTRAKT-fable-artdirection.md` |
|
||||
| **Протокол всех решений владельца** | `docs/grilling/2026-08-06-lending-peredelka.md` |
|
||||
| Контактный лист эталона, 32 кадра | `docs/superpowers/prototypes/etalon-activetheory-sheet-32.jpg` |
|
||||
| 18 сырых ответов моделей | `docs/research/2026-08-06-lending-*` |
|
||||
| Отменённый промт под Fable на подписке | `docs/superpowers/2026-08-06-PROMT-lending-fable5.md` |
|
||||
|
||||
**Читать первым делом:** протокол гриллинга целиком — там все решения владельца,
|
||||
отрезанные ветки и открытые вопросы.
|
||||
|
||||
---
|
||||
|
||||
## 3. gen-api — что проверено командой
|
||||
|
||||
Ключ владельца передан в переписке (начинается на `sk-6Sz4…`). **В файлы не записан.**
|
||||
Владельцу сказано перевыпустить его после работы.
|
||||
|
||||
| Что | Замер |
|
||||
|---|---|
|
||||
| Ключ | Живой |
|
||||
| Счёт | На имя «Саша Кондратьева», `Sasha261185@yandex.ru` |
|
||||
| Баланс | **2000,3 ₽** (проверено запросом и подтверждено снимком экрана владельца в 19:20) |
|
||||
| Адрес запуска | `POST https://api.gen-api.ru/api/v1/networks/<слуг>` |
|
||||
| Заголовок | `Authorization: Bearer <ключ>`, `Content-Type: application/json` |
|
||||
| Ответ на запуск | `{"request_id": <число>, "model": "...", "status": "processing"}` |
|
||||
| Получение результата | `GET https://api.gen-api.ru/api/v1/request/get/<request_id>` |
|
||||
| Свой счёт | `GET https://api.gen-api.ru/api/v1/user` |
|
||||
| Списка моделей в API нет | `/api/v1/networks`, `/api/v1/models`, `/api/v1/catalog` — 404 |
|
||||
| Очередь | Наша запись от 15.07 (`docs/superpowers/findings/2026-07-14-golos-nastroyki-i-rezultaty.md`): gen-api устроен как очередь, ответ ~5 с даже при `is_sync=true`. **Для выпечки заготовок это неважно** — владелец на это указал отдельно |
|
||||
|
||||
### 🔴 Ошибка прежней смены — не повторить
|
||||
|
||||
Прежняя смена **выдумывала названия моделей** и проверяла их пустым POST-запросом.
|
||||
На `tripo`, `tripo3d`, `meshy`, `veo`, `ltx`, `seedream`, `gpt-image` пришёл 404, и она
|
||||
объявила, **что этих моделей на счёте нет**.
|
||||
|
||||
**Это неверно.** Владелец прислал снимок раздела «Нейросети категории: 3D» на gen-api.ru,
|
||||
где видны: **Tripo3D H3.1 · Meshy V6 · Hunyuan-Motion · Trellis 2 · Rodin · HunYuan 3D**.
|
||||
404 означал только то, что **слуг угадан неправильно**.
|
||||
|
||||
**Правильный способ узнать слуг:** открыть карточку модели на сайте и взять слуг из
|
||||
адреса `gen-api.ru/model/<слуг>`. Сайт рисуется скриптом, `WebFetch` списки не видит —
|
||||
брать **Playwright** и читать отрисованную страницу, либо спросить владельца.
|
||||
|
||||
### Слуги, проверенные по-настоящему (пустой POST → 422 «нужно поле»)
|
||||
|
||||
| Слуг | Что просит | Раздел |
|
||||
|---|---|---|
|
||||
| `flux` | `prompt` | картинки |
|
||||
| `nano-banana` | `prompt` | картинки |
|
||||
| `midjourney` | `prompt` | картинки |
|
||||
| `rodin` | `prompt` | 3D по описанию |
|
||||
| `hunyuan-3d` | `input_image_url` | 3D по картинке |
|
||||
| `trellis` | `image_url` | 3D по картинке |
|
||||
| `kling` | `prompt` | видео |
|
||||
| `ltx-2` | `prompt` | видео |
|
||||
|
||||
Это **не полный список** — только то, что случайно угадали. Остальные слуги неизвестны.
|
||||
|
||||
### Замер по flux
|
||||
|
||||
Запуск с `width: 1024, height: 512` вернул ошибку:
|
||||
|
||||
> «Неверное разрешение изображения. Допустимые разрешения: **3136x1344, 2048x2048,
|
||||
> 2752x1536, 2368x1792, 1344x3136, 1536x2752, 1792x2368**.»
|
||||
|
||||
Соотношения 2:1 среди них нет. Деньги за неудачный запуск **не списаны** — баланс на
|
||||
снимке владельца после попытки прежний, 2000,3 ₽.
|
||||
|
||||
Цены flux с сайта (за мегапиксель): PRO 9 ₽ · PRO v1.1 7,2 ₽ · Dev 4,5 ₽ ·
|
||||
**Schnell 0,5 ₽** · Realism 6,5 ₽ · image-to-image 5,5 ₽ · inpainting 13,5 ₽ · ultra 13 ₽.
|
||||
Как выбрать вариант через API — **не выяснено**.
|
||||
|
||||
### Рабочий скрипт запуска
|
||||
|
||||
`<скратчпад прежней сессии>/gen.mjs` — POST, затем опрос `request/get/<id>` раз в 4 с.
|
||||
Ключ берёт из переменной окружения `GENAPI_KEY`, в файл не пишет. Скрипт во временной
|
||||
папке, при новой сессии его не будет — написать заново по описанию выше.
|
||||
|
||||
---
|
||||
|
||||
## 4. Что владелец решил раньше (кратко, полностью — в протоколе)
|
||||
|
||||
- **Устройство — как в эталоне** `моя/лендинг/video-06-08-26-10-41.mov`: непрерывный
|
||||
трёхмерный мир, летящая камера, объёмный заголовок внутри сцены, парящие панели,
|
||||
нет шапки/меню/подвала, две мелкие накладки по углам, прокрутка двигает камеру.
|
||||
- **Восемь глав.** Список и содержание — в промте субагенту, раздел 6.
|
||||
- **Цена «от 25 ₽ за контакт».** ⚠️ В `app/database/seeders/PricingTierSeeder.php`
|
||||
лежат **тестовые** цены 500…250 ₽ — по ним не сверять.
|
||||
- **Шесть рекламных каналов без пометок «в разработке»**.
|
||||
- **Показ только по `liderra.ru/new`**, главную не трогать, подмена — после «да».
|
||||
- **Полный трёхмерный полёт на телефоне обязателен**, упрощённого режима нет.
|
||||
- **Форм заявки нет, юридических ссылок нет** (риск принят владельцем).
|
||||
- **Рекламная картинка `моя/лендинг/image-06-08-26-10-41.png`** — источник содержания
|
||||
и зрительного языка: тёмный фон, бирюзовое свечение, золото на «контактах», знак из
|
||||
узлов и линий, россыпь точек → кольцо → воронка → капли в кабинет.
|
||||
**Посмотреть её глазами обязательно.**
|
||||
|
||||
### Четыре отклонённые сборки
|
||||
|
||||
| № | Что было | Отзыв владельца |
|
||||
|---|---|---|
|
||||
| 1 | Перекрашенный старый лендинг | «просто перелопатил наш лендинг» |
|
||||
| 2 | Устройство эталона плоскими средствами | хром вышел белой лентой |
|
||||
| 3 | Настоящий three.js, стекло, хром, перелив | «так гавно это все» |
|
||||
| 4 | Прощающие материалы, но обычная страница с трёхмерным фоном | «ты по сути фон заменил» |
|
||||
|
||||
---
|
||||
|
||||
## 5. Открытые вопросы
|
||||
|
||||
1. 🔴 **Где именно проходит граница «максимально к эталону»** — снимаем ли Р-К6 целиком
|
||||
(можно любые файлы картинок, моделей и видео) или частично.
|
||||
2. 🔴 **Три документа не переписаны** под это указание: в них жёсткий запрет картинок,
|
||||
запрет хрома и стекла, потолки веса 1,5/3 МБ и красная проверка приёмки на любой
|
||||
файл изображения. Всё это противоречит «максимально к эталону».
|
||||
3. **Добро на трату с этого счёта прямо не дано.** Владелец спросил «как можно
|
||||
предложи», прежняя смена ответила предложением на ~160 ₽ и начала тратить, не
|
||||
получив отдельного «да». Уточнить.
|
||||
4. **Приёмочный скрипт не написан.** По спецификации его пишет контроллер и кладёт в
|
||||
`C:\acceptance\liderra-3d\` — вне рабочей папки.
|
||||
5. **Слуги моделей 3D и видео неизвестны.** Их надо взять с сайта, а не угадывать.
|
||||
6. **Вариант flux (Schnell/Dev/PRO) через API выбирать не умеем.**
|
||||
7. **Расхождение лендинг ↔ кабинет** по ИИ колцентру и VK Рекламе — владелец отложил.
|
||||
8. **Политики обработки ПДн на лендинге нет** при работающем Вебвизоре — риск принят
|
||||
владельцем, не устранён.
|
||||
|
||||
---
|
||||
|
||||
## 6. Грабли, каждая стоила заходов
|
||||
|
||||
- **Видео-эталон в H.265 (hvc1, 888×1920, 55,6 кадра/с).** Chrome на этой машине его
|
||||
не декодирует, ffmpeg из Playwright тоже («Invalid data found»). Лечение:
|
||||
`npm i ffmpeg-static` **во временную папку**, не в проект. Ставится за 8 секунд.
|
||||
- 🔴 **Флаг `--use-gl=swiftshader` ломает трёхмерность молча** — контекст создаётся и
|
||||
теряется, кадр белый, ошибок нет. Рабочие флаги:
|
||||
`--use-angle=swiftshader --enable-unsafe-swiftshader`. Датчик — `gl.isContextLost()`.
|
||||
- **`npm install --no-save` выпиливает ранее поставленное** — ставить всё одной командой.
|
||||
- **`three` 0.180 разбит на два файла** — `three.module.js` тянет `three.core.js`.
|
||||
Копировать оба, иначе модуль молча не грузится: страница живая, сцена пустая.
|
||||
- **`IcosahedronGeometry` не индексирована** (`geo.index === null`) — обход рёбер падает.
|
||||
Лечение `.toNonIndexed()` и дедупликация по округлённым координатам.
|
||||
- **`String.replace('маркер', код)` молча портит код** — `$&` в строке-замене особая
|
||||
последовательность. Только `.replace(m, () => код)`.
|
||||
- **Менять `material.transparent` каждый кадр нельзя** — движок пересобирает шейдеры,
|
||||
страница встаёт.
|
||||
- **Правки файлов — только Edit/Write.** Файл, записанный из-под Bash, может молча
|
||||
исчезнуть.
|
||||
- **Многострочные строки из PowerShell в Bash** — использовать heredoc, не `@'…'@`.
|
||||
|
||||
### Грабли коммита в этом репозитории
|
||||
|
||||
- **В репозитории работает соседняя смена.** Её файлы попадают в индекс при неудачном
|
||||
прогоне хуков — перед коммитом **всегда** сверять список
|
||||
`git diff --cached --name-only` и вычищать чужое.
|
||||
- **`docs/observer/STATUS.md` не пре-стейджить** — сам лезет в индекс.
|
||||
- **Хук-исправитель разметки при неудаче откатывает файлы**, в том числе
|
||||
`cspell-words.txt` — терялись и свои слова, и чужие.
|
||||
- **`docs/superpowers/` не линтуется** (`.markdownlintignore`), а `docs/research/` —
|
||||
линтуется. Сырые ответы Perplexity содержат номера источников вида `[4][5]`,
|
||||
markdown принимает их за битые ссылки. Лечение — экранировать открывающую скобку
|
||||
`\[4]`, текст выглядит так же.
|
||||
- **Ключи API в файлы не писать.** В репозитории уже лежит один настоящий старый ключ
|
||||
aitunnel в `docs/superpowers/plans/2026-06-30-KONTEKST-portal-live.md` — не тиражировать.
|
||||
|
||||
---
|
||||
|
||||
## 7. Правила работы с владельцем
|
||||
|
||||
- Он **не программист**. Говорить простым русским, без программистских слов.
|
||||
- Он **не умеет объяснять словами, что не так** — он умеет **выбрать из показанного**.
|
||||
Четыре сборки отклонены именно на этом.
|
||||
- Он **замечает срезанные углы**. Прежнюю смену остановил именно за вывод, сделанный
|
||||
без проверки. Не выдавать догадку за замер.
|
||||
- Ничего необратимого без его разрешения: не коммитить чужое, не выкатывать на боевой,
|
||||
не тратить деньги без прямого «да».
|
||||
- 🔴 **Боевой `liderra.ru` — живые клиенты и деньги.** Главная не тронута,
|
||||
`curl -s https://liderra.ru/ | md5sum` = `5046a638f9e311f2c4b073721112b6b1`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Первое действие следующей смены
|
||||
|
||||
1. Прочитать протокол гриллинга целиком.
|
||||
2. Посмотреть глазами оба источника: контактный лист эталона и рекламную картинку.
|
||||
3. Спросить владельца, где проходит граница «максимально к эталону» — что из запретов
|
||||
Р-К6 снимается, и даёт ли он добро на трату с счёта gen-api.
|
||||
4. Взять настоящие слуги моделей с сайта gen-api.ru (Playwright, не WebFetch).
|
||||
5. Только после этого переписывать три документа и что-либо строить.
|
||||
@@ -0,0 +1,106 @@
|
||||
# ПРОМТ для следующей сессии — вкладка «Активность» в карточке клиента
|
||||
|
||||
Скопировать всё, что ниже разделителя, и вставить первым сообщением новой сессии.
|
||||
|
||||
---
|
||||
|
||||
Продолжаем работу над вкладкой «Активность» в карточке клиента админки Лидерры.
|
||||
Спека и план уже написаны и утверждены владельцем. **Код ещё не начат.**
|
||||
|
||||
## Что читать первым делом
|
||||
|
||||
1. `docs/superpowers/plans/2026-08-07-aktivnost-tenanta-zhiv-li-klient.md` — план,
|
||||
11 задач по TDD. Идти строго по нему.
|
||||
2. `docs/superpowers/specs/2026-08-07-aktivnost-tenanta-zhiv-li-klient-design.md` —
|
||||
спека с решениями владельца и замерами боевого.
|
||||
|
||||
Ветка: `feat/prospects-manual-testing-kp`.
|
||||
Коммиты уже в ней: `c9daf48b` (спека), `16d8191d` (план).
|
||||
|
||||
## Задача одной фразой
|
||||
|
||||
Вкладка «Активность» сейчас показывает журнал сделок, где 364 строки из 419 —
|
||||
системные «робот залил лид». Переделываем её так, чтобы она одним взглядом
|
||||
отвечала **«жив ли клиент»**: вердикт с цветом, полоска посещений на 90 дней,
|
||||
четыре счётчика и лента только человеческих дел. Заодно оживляем мёртвую
|
||||
сортировку списка клиентов по последней активности.
|
||||
|
||||
## Решения владельца — не переспрашивать, не менять
|
||||
|
||||
| Вопрос | Решение |
|
||||
|---|---|
|
||||
| Задача вкладки | «Жив ли клиент» |
|
||||
| Форма | Вердикт + полоска дней + лента |
|
||||
| Пороги | 3 суток (остывает) и 14 суток (пропал) |
|
||||
| Состав ленты | Проекты, ручная работа со сделками, деньги |
|
||||
| Входы в ленте | Нет. Только в верхнем блоке |
|
||||
| Границы | Вкладка + список клиентов + колонка на вкладке «Пользователи» |
|
||||
|
||||
## Что уже замерено на боевом — не перемерять
|
||||
|
||||
- `activity_log`: `deal.created` — 364 строки, **все системные**;
|
||||
`deal.status_changed` — 29 и `deal.commented` — 26, все человеческие.
|
||||
- `auth_log` живой: 106 входов у тенанта 10, 45 у первого, 45 у второго,
|
||||
1 у `ekb96` (id 11). Есть `ip_address` и `user_agent`.
|
||||
- `tenant_operations_log` живой: 146 созданий проекта на всю базу.
|
||||
**Имя проекта есть в payload:** у `project.created` в `payload_after->>'name'`
|
||||
(146 из 146), у `project.deleted` в `payload_before->>'name'` (10 из 10).
|
||||
- `users.last_active_at` — заполнен у **0 из 11**. Мёртвый, никто не пишет.
|
||||
- `tenants.last_activity_at` — заполнен у **0 из 11**. Мёртвый, а список
|
||||
клиентов по нему сортируется.
|
||||
- `users.last_login_at` — заполнен у **7 из 11**. Живой, на него и переходим.
|
||||
- Просмотров страниц портал не пишет вообще. Показать «ходил по разделу Лиды»
|
||||
нечем — не пытаться.
|
||||
|
||||
## Как посмотреть боевую базу на чтение, если понадобится
|
||||
|
||||
Только чтение. Ничего не менять.
|
||||
|
||||
```bash
|
||||
cat > /tmp/q.sh <<'EOF'
|
||||
cd /var/www/liderra/app
|
||||
set -a; . ./.env; set +a
|
||||
export PGPASSWORD="$DB_ADMIN_PASSWORD"
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_ADMIN_USERNAME" -d "$DB_DATABASE" <<'SQL'
|
||||
SELECT 1;
|
||||
SQL
|
||||
EOF
|
||||
ssh liderra 'sudo bash -s' < /tmp/q.sh
|
||||
```
|
||||
|
||||
Роль `crm_app_user` из `.env` упрётся в RLS и вернёт ноль строк — нужен именно
|
||||
`DB_ADMIN_USERNAME`/`DB_ADMIN_PASSWORD`. Кавычки в одну строку через `ssh "..."`
|
||||
ломаются — только через heredoc, как выше.
|
||||
|
||||
## Открытый вопрос — задать владельцу первым делом
|
||||
|
||||
Как выполнять план: **через субагентов** (свежий исполнитель на задачу, разбор
|
||||
между задачами — рекомендовано) или **прямо в сессии**. Владелец на этот вопрос
|
||||
ещё не ответил.
|
||||
|
||||
После ответа — вызвать соответствующий скил:
|
||||
`superpowers:subagent-driven-development` или `superpowers:executing-plans`.
|
||||
|
||||
## Правила этой работы
|
||||
|
||||
- **Боевой прод liderra.ru — живые клиенты и деньги.** Ничего туда не
|
||||
выкатывать. База — только чтение. Выкат отдельным решением владельца.
|
||||
- Разговаривать с владельцем простым русским, он не программист.
|
||||
- «Готово» говорить только после реального прогона команды с зелёным выводом.
|
||||
- **Сторожа проверять красным.** В плане есть два шага, где нужно нарочно
|
||||
сломать код и убедиться, что тест покраснел. Не пропускать: зелёный тест,
|
||||
которого не видели красным, ничего не охраняет.
|
||||
- **Счётчик запросов** в тесте списка клиентов — обязателен. Вопрос к базе
|
||||
внутри перебора ловился в проекте дважды за одну неделю.
|
||||
- Коммитить самому по ходу задач, как написано в плане, но просить эскейп
|
||||
у стены «роутер-наставник».
|
||||
- Границы суток — **московские**, через `App\Support\MskBoundary`. Гринвичская
|
||||
полночь — это три часа ночи по Москве, на этом уже обжигались.
|
||||
|
||||
## Чего в этой работе НЕ делаем
|
||||
|
||||
- Не пишем просмотры страниц — нечем.
|
||||
- Не удаляем мёртвые колонки из схемы БД — отдельная уборка со своей записью
|
||||
в журнале схемы.
|
||||
- Не трогаем вкладку «Финансы».
|
||||
- Не выкатываем на боевой.
|
||||
@@ -1,5 +1,12 @@
|
||||
# ПРОМТ смене 5 — «Обзвон»: ТЫ СТРОИШЬ. Развилки разобраны, очередь работ готова
|
||||
|
||||
> ⛔ **ЭТОТ ПРОМТ ОТРАБОТАН 06.08.2026. Смена 5 по нему прошла — четыре круга.**
|
||||
> **Смене 6 читать не его, а [2026-08-09-PROMT-obzvon-stroyka-3.md](2026-08-09-PROMT-obzvon-stroyka-3.md).**
|
||||
>
|
||||
> Здесь оставлено то, чего в новом промте нет: устройство робота, доступ на
|
||||
> сервер и разбор решений владельца Р84–Р115. Числа в нём **протухли** —
|
||||
> собирай заново командой.
|
||||
|
||||
**Переписан 06.08.2026 поздним вечером.** Между сменой 4 и тобой прошла **не
|
||||
стройка, а разбор развилок с владельцем**: кода никто не трогал, но решений стало
|
||||
на **одиннадцать** больше, задач в плане на **три** больше, и у стройки появился
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
> ⛔ **ЭТОТ ПРОМТ ОТРАБОТАН 07.08.2026. Смена 6 по нему прошла — шесть кругов.**
|
||||
> **Смене 7 читать не его, а [2026-08-10-PROMT-obzvon-stroyka-4.md](2026-08-10-PROMT-obzvon-stroyka-4.md).**
|
||||
> Здесь остаются решения владельца Р84–Р121 и устройство робота — за ними сюда
|
||||
> и приходят.
|
||||
|
||||
# ПРОМТ смене 6 — «Обзвон»: ТЫ СТРОИШЬ. Волна 2 закрыта, впереди самое дорогое
|
||||
|
||||
**Написан 06.08.2026 поздним вечером, в конце смены 5.** Смена 5 была
|
||||
**стройкой**: четыре круга, две задачи закрыты полностью, третья закрыта с
|
||||
находкой крупнее себя, четвёртая осталась в работе. Владелец закрыл ещё
|
||||
**шесть** развилок — Р116–Р121.
|
||||
|
||||
🔴🔴🔴 **ПЕРВОЕ ДЕЙСТВИЕ — ПРОЧЕСТЬ МЕТОД ЦЕЛИКОМ. Не пересказ, не «я помню».**
|
||||
519 строк, в нашей ветке его нет:
|
||||
|
||||
```bash
|
||||
git show 5cb09ae7e:docs/superpowers/METOD-vedeniya-fichi-v1.md
|
||||
```
|
||||
|
||||
**Владелец требует этого прямым словом каждую смену.** Правила стройки одним
|
||||
списком — §0.0, и они не советы.
|
||||
|
||||
🔴 **Главное, что изменилось — шесть вещей:**
|
||||
|
||||
1. ✅ **Волна 2 ЗАКРЫТА ЦЕЛИКОМ.** Последняя её задача З-2.4 построена: статус
|
||||
обзвона виден в карточке сделки, лента разговоров, пометки касаний.
|
||||
2. 🔴 **Твоя настоящая мерка выросла: обзвон и сделки — 447 проверок.** Полный
|
||||
прогон на входе смены 6 меряй сам, у смены 5 он был **5166, красных 0**.
|
||||
3. 🔴🔴 **`php artisan test` для полного прогона НЕГОДЕН** — падает по нехватке
|
||||
памяти с выходом **255**, а обёртка среды печатает «выход 0». Годно только
|
||||
`php -d memory_limit=-1 vendor/bin/pest`. Подробности — §0.7.
|
||||
4. 🆕 **Владелец закрыл шесть развилок — Р116–Р121.** Разбор в §5.1. Из них
|
||||
Р116 и Р117 назначили устройство З-0.10, Р120 и Р121 закрыли два хвоста
|
||||
внутри З-2.4.
|
||||
5. 🔴 **З-0.10 осталась В РАБОТЕ на момент передачи** — помощник писал её, когда
|
||||
смена кончилась. Что с ней делать первым делом — §4.1.
|
||||
6. **Ошибок надзирателя стало 47** — смена 5 прибавила двенадцать, и **все
|
||||
двенадцать одного класса**: прибор мерил не то, о чём его спрашивали, и
|
||||
отвечал осмысленно на вид. Правил стало 24.
|
||||
|
||||
---
|
||||
|
||||
## 0.0. 🔴🔴🔴 ПРАВИЛА СТРОЙКИ — ПРОЧТИ ДО ТОГО, КАК ТРОНЕШЬ ХОТЬ ОДИН ФАЙЛ
|
||||
|
||||
**Кто ты.** Надзиратель. 🔴 **Своей рукой кода не пишешь.** Четыре обязанности:
|
||||
**задания помощникам · приборы · приговор своей рукой · бумаги.** Единственное
|
||||
исключение — диагностический прибор.
|
||||
|
||||
🔑 **Почему так.** Тот, кто пишет код, защищает свой код. Надзиратель,
|
||||
взявшийся чинить сам, теряет способность выносить приговор.
|
||||
|
||||
**Как идёт круг — семь шагов, ни один не пропускается:**
|
||||
|
||||
1. **Один круг = ОДНА задача плана.**
|
||||
2. **Завести приёмочный лист ДО работы.** Куда класть — §0.9.
|
||||
3. **Задание помощнику переписать ЦЕЛИКОМ.** Он не видел твоего разговора.
|
||||
Проверки плана перенести **дословно**.
|
||||
4. **В задание — просьба о разборе** (§0.4, дословно) и **полный список
|
||||
запрещённых файлов** (§0.5).
|
||||
5. **Резать СВОИМ ножом.** Сторож обязан быть показан **красным** — и не тем
|
||||
ножом, каким резал исполнитель. Плюс **вырезание второго рода**: назови
|
||||
вслух допущение защиты и найди годный по форме случай **мимо** него.
|
||||
6. **Всё вырезанное вернуть и доказать слепком** — 🪤 **со снятием невидимых
|
||||
знаков конца строки**, иначе прибор соврёт (ошибки 37 и 39).
|
||||
7. **Приговор своей рукой** в лист, и **бумаги обновить в том же круге**.
|
||||
|
||||
**Шесть правил, которые нарушают чаще всего:**
|
||||
|
||||
- 🔴 **«Отчёт помощника — не доказательство. И отчёт судьи тоже.»**
|
||||
- 🔴 **Свою ошибку называешь ПЕРВОЙ**, до находок. Всегда.
|
||||
- 🔴 **Всякий перечень собирается КОМАНДОЙ, а не глазами.**
|
||||
- 🔴 **Приговор «этого нет» — только вторым прибором.** «Ноль» — самый опасный
|
||||
ответ прибора.
|
||||
- 🔴 **«Построено» и «работает» — разные утверждения.** Спрашивай третье:
|
||||
**доехало ли до жизни.**
|
||||
- 🆕 🔴🔴 **Развилку владельцу выносят с ПОЛНЫМ набором дорог.** Оплачено
|
||||
ошибкой 43: смена 5 дала владельцу три дороги, а четвёртая — лучшая — нашлась
|
||||
уже после его решения. Владелец решал по неполной карте, и виноват в этом был
|
||||
надзиратель.
|
||||
|
||||
**Чего не делаешь:**
|
||||
|
||||
- не переписываешь спеку, план и протокол — они закрыты до тебя;
|
||||
- не переоткрываешь решения владельца Р1–Р121;
|
||||
- не пушишь и не выкатываешь без его слова;
|
||||
- не трогаешь файлы соседних смен (§0.5) и не разрешаешь чужие столкновения;
|
||||
- 🔴 **не правишь файлы через оболочку** — только средствами правки. Оплачено
|
||||
ошибкой 45, и это уже второй раз за две смены.
|
||||
|
||||
---
|
||||
|
||||
## 0.1. Метод — прочесть ЦЕЛИКОМ, до первого действия
|
||||
|
||||
В нашей ветке его нет, 519 строк:
|
||||
|
||||
```bash
|
||||
git show 5cb09ae7e:docs/superpowers/METOD-vedeniya-fichi-v1.md
|
||||
```
|
||||
|
||||
🔴 **Не пересказ, не выжимка, не «я помню» — целиком.**
|
||||
|
||||
---
|
||||
|
||||
## 0.2. 🔴 ЛИСТ ОБЯЗАННОСТЕЙ — ПРОЙТИ ПОИМЁННО НА КАЖДОМ КРУГЕ
|
||||
|
||||
| # | обязанность | чем доказывается |
|
||||
|---|---|---|
|
||||
| 1 | **круг = ОДНА задача плана** | не две, не половина |
|
||||
| 2 | **задание переписано ЦЕЛИКОМ** | помощник не знает ничего |
|
||||
| 3 | **проверки плана перенесены ДОСЛОВНО** | потерянная оговорка отменяет решение владельца |
|
||||
| 4 | **список запрещённых файлов ПОЛНЫЙ** | §0.5 |
|
||||
| 5 | **просьба о разборе — дословно** | §0.4, самый урожайный приём |
|
||||
| 6 | **первым действием помощника — файл отчёта со скелетом** | «пиши по ходу» — условие |
|
||||
| 7 | 🔴 **сторож показан КРАСНЫМ, и режешь СВОИМ ножом** | не тем, каким резал исполнитель |
|
||||
| 8 | 🔴 **возврат доказан слепком СО СНЯТИЕМ `\r`** | сырое сравнение на Windows врёт |
|
||||
| 9 | 🔴 **полный прогон СВОЕЙ рукой** | и на **своей отдельной базе**, см. §0.7 |
|
||||
| 10 | **две строки про соседей** | дословно |
|
||||
| 11 | **зовущие поимённо** — глазами, не числом | счётчик врёт нулём восемью способами |
|
||||
| 12 | 🔴 **вырезание ВТОРОГО рода** | назови допущение и найди случай мимо |
|
||||
| 13 | **приговор записан в приёмочный лист** | лист заводится **ДО** работы |
|
||||
| 14 | **между кругами не останавливаешься** | владельца спрашивают на стене или границе |
|
||||
| 15 | 🔴 **бумаги обновлены В ТОМ ЖЕ КРУГЕ** | план, лист, протокол |
|
||||
|
||||
🔑 **Что вырезание второго рода дало смене 5 — три круга из четырёх:**
|
||||
|
||||
- 🔴 **З-0.9:** сторож заготовок отвечал **зелёным на подложенном файле** в
|
||||
проверке «личные данные не утекли». Единственный замок, который сработал, был
|
||||
**случайным** — и отваливался ровно тогда, когда файл клали и на машину, как
|
||||
велит инструкция самого же сторожа;
|
||||
- 🔴 **З-0.11:** число «четыре» из плана оказалось **четырнадцатью**, и три
|
||||
самых опасных места были **вне списка**;
|
||||
- 🔴 **З-2.4:** защита от записи мимо двери **не ловила правку через связь** —
|
||||
8 из 8 зелёных на подставном файле. Закрыто своим построителем.
|
||||
|
||||
---
|
||||
|
||||
## 0.3. 🔴 СОРОК СЕМЬ ОШИБОК НАДЗИРАТЕЛЯ — ВСЕ ОДНОГО КЛАССА
|
||||
|
||||
Класс у всех один: **утверждение без замера**.
|
||||
|
||||
**Ошибки 1–35** (смены 1–4 и разбор развилок) — сжато: не открыл план и сказал
|
||||
«такого нет» · пересказал проверку и потерял оговорку · передал чужой отчёт как
|
||||
факт · собрал список глазами · предложил приём, не запустив его · назвал папку,
|
||||
которой нет · написал «пустой прогон виден в журнале», а на бою журнал глотает
|
||||
такие записи · вынес приговор «подписи нет» первым прибором · **дважды написал
|
||||
команду с диапазоном русских букв в тот же день, когда сам вписывал про это
|
||||
правило** · поправил числа командой `sed` вопреки прямому запрету.
|
||||
|
||||
**Ошибки 36–47 — смена 5, все двенадцать мои:**
|
||||
|
||||
| # | что сделал | чего не сделал | чем кончилось |
|
||||
|---|---|---|---|
|
||||
| 36 | сверял слепки хранилища с машиной | не проверил, **одинаково ли прибор печатает имя** на обеих сторонах | на Windows он приписывает звёздочку — ложное «не сошлось 43 из 43» |
|
||||
| 37 | сверял возврат вырезанного слепком | не снял **невидимые знаки конца строки** | ложное «разошлось», **дважды за круг** |
|
||||
| 38 | 🔴 задумал нож «порча при том же размере» | **не прочитал, на чём стоит защита** | нож был бы холостым. Спасла среда, а не голова |
|
||||
| 39 | принял прибор, **предложенный помощником** | не запустил его сам | `cmp` разошёлся на 123-м знаке: та же грабля со знаками конца строки |
|
||||
| 40 | прочёл надпись «показаны выдуманные события» | не замерил | лента оказалась настоящей. Поправился **до** того, как сказал вслух |
|
||||
| 41 | 🔴 написал в задание число «четыре» из плана | не собрал перечень сам | мест было **четырнадцать**. Спасла только собственная оговорка «числу не верю» |
|
||||
| 42 | считал пометки прибором с дословной подписью | не проверил образец на строке, которую он обязан поймать | получил 13 вместо 14 и чуть не обвинил помощника |
|
||||
| 43 | 🔴🔴 **вынес владельцу развилку с НЕПОЛНЫМ набором дорог** | не спросил у самого сторожа, умеет ли он что-то ещё | четвёртая дорога нашлась **после** решения владельца и была лучше всех трёх |
|
||||
| 44 | сказал владельцу «пять слов» | не проверил, что считает прибор | их четыре: сторож считает **случаи, а не слова** |
|
||||
| 45 | 🔴 правил файл **командой оболочки** | не вспомнил прямое правило | второй раз за две смены. Ущерба нет, правило нарушено |
|
||||
| 46 | запустил **два прогона на одной базе** | не подумал, что база пересобирается в начале каждого | прогон упал, и падение **выглядело чужим** |
|
||||
| 47 | 🔴 запустил проверки командой, которую оболочка не приняла | не посмотрел на **число проверок** в выводе | прогона не было, а код возврата пришёл **ноль** |
|
||||
|
||||
🔑 **Ошибка 46 стоит отдельного взгляда.** Падение легло в **чужой файл**, и
|
||||
промт прошлой смены прямо разрешал списать такое на соседей. Списал бы — и в
|
||||
бумагах осталась бы запись «реклама роняет прогон»: чужая вина за мою ошибку.
|
||||
Спасло правило метода: **увидел красноту у соседа — сперва проверь, не ты ли
|
||||
её навёл.**
|
||||
|
||||
### 🔑 ДВАДЦАТЬ ЧЕТЫРЕ ПРАВИЛА. Двадцать перенесены, четыре добыты сменой 5
|
||||
|
||||
1. **Проверки плана переносить в задание ДОСЛОВНО.**
|
||||
2. **Всё, что план утверждает о коде, сверять командой ДО задания.**
|
||||
3. 🔴 **Прежде чем сказать «такого нет» — ОТКРОЙ и посмотри.**
|
||||
4. **Мерить и тогда, когда чужой отчёт говорит, что ты неправ.**
|
||||
5. 🔴 **Команду, которую вписываешь в бумагу, — ЗАПУСТИ.**
|
||||
6. 🔴 **Список запрещённых файлов писать ПОЛНЫМ.**
|
||||
7. **В каждое задание — просьба о разборе.**
|
||||
8. 🔴 **Своя ошибка называется ПЕРВОЙ.**
|
||||
9. 🔴 **Бумаги проверять командой так же, как код.**
|
||||
10. 🔴🔴 **Разведка обязана покрывать и ТВОИ СОБСТВЕННЫЕ дописки к заданию.**
|
||||
11. 🔴🔴 **ВСЯКИЙ ПЕРЕЧЕНЬ СОБИРАТЬ КОМАНДОЙ, А НЕ ГЛАЗАМИ.**
|
||||
12. 🔴🔴 **Приём, который ты СОВЕТУЕШЬ помощнику, меряй строже своего.**
|
||||
13. 🔴🔴 **«ПОСТРОЕНО» И «РАБОТАЕТ» — РАЗНЫЕ УТВЕРЖДЕНИЯ.**
|
||||
14. 🔴🔴 **ПРИБОР МЕРИТ ТО, ЧЕМ ТЫ ЕГО ЗАПУСТИЛ, А НЕ ТО, О ЧЁМ СПРАШИВАЕШЬ.**
|
||||
15. 🔴🔴 **СПОСОБ ЛЕЧЕНИЯ, ВЗЯТЫЙ У СОСЕДА, ПРОВЕРЯЙ НА СВОЁМ ПРЕДМЕТЕ.**
|
||||
16. 🔴 **СЧЁТ КОММИТОВ НАЗАД — НЕГОДНЫЙ ПРИБОР В ОБЩЕЙ ВЕТКЕ.**
|
||||
17. 🔴🔴 **«ПРИРОСТ РАВЕН ЧИСЛУ СТОРОЖЕЙ» — НЕ ДАТЧИК на движущемся дереве.**
|
||||
18. 🔴🔴 **ПРИБОР ОБЯЗАН УМЕТЬ ТО, ЧЕГО ТЫ ОТ НЕГО ХОЧЕШЬ — ЗАПУСТИ ЕГО НА
|
||||
ПОДСТАВНОЙ СТРОКЕ.** `grep -i` здесь **не складывает регистр кириллицы
|
||||
никаким ключом**.
|
||||
19. 🔴🔴 **ЧУЖОЙ КОММЕНТАРИЙ О ЗАЩИТЕ — НЕ ЗАМЕР, А ОБЕЩАНИЕ.**
|
||||
20. 🔴🔴 **«ПРОГОН КРАСНЫЙ, ПРИЧИНА НЕИЗВЕСТНА» — часто значит, что ты задал не
|
||||
тот вопрос.** Годный: **кто ещё работает в этом дереве прямо сейчас.**
|
||||
21. 🆕 🔴🔴 **ДАТЧИК ПРОГОНА — НЕ КОД ВОЗВРАТА, А ЧИСЛО ПРОВЕРОК В ВЫВОДЕ.**
|
||||
Оплачено ошибками 46 и 47. Прогон падал по памяти с выходом 255, а обёртка
|
||||
печатала «выход 0»; другой прогон **не запустился вовсе** и вернул ноль.
|
||||
🔑 И проверяй **арифметику**: `зелёных + красных + ошибок + пропущено`
|
||||
обязано равняться общему числу. Помощник смены 5 поймал так 44 падения,
|
||||
спрятанных в поле «ошибок» при `"провалов": 0`.
|
||||
22. 🆕 🔴🔴 **СВОЯ БАЗА НА ПРОГОН, А НЕ НА СМЕНУ.** Тестовая база пересобирается
|
||||
в начале **каждого** прогона. Два прогона на одной базе роняют друг друга, и
|
||||
падение выглядит чужим.
|
||||
23. 🆕 🔴 **«КОММИТ ПОИМЁННО» ЗАЩИЩАЕТ ОТ ЗАХВАТА ЧУЖИХ ФАЙЛОВ, НО НЕ ЧУЖИХ
|
||||
СТРОК ВНУТРИ ОБЩЕГО ФАЙЛА.** Помощник смены 5 подписал своим коммитом
|
||||
**152 чужие строки** общего словаря: замер счётчиком был верен и **устарел
|
||||
за минуты**. Годная мерка для общего файла — **содержание, прочитанное
|
||||
вплотную перед коммитом**.
|
||||
24. 🆕 🔴🔴 **РАЗВИЛКУ ВЛАДЕЛЬЦУ ВЫНОСЯТ С ПОЛНЫМ НАБОРОМ ДОРОГ.** Оплачено
|
||||
ошибкой 43. Перед вопросом — разведка: спроси у самого предмета, чего он
|
||||
умеет, а не только у своей памяти.
|
||||
|
||||
---
|
||||
|
||||
## 0.4. Просьба о разборе — вставлять в КАЖДОЕ задание дословно
|
||||
|
||||
> Это задание писал я, и я **регулярно вкладываю в него собственные ошибки**.
|
||||
> Разбери задание по существу. Если моя картина беды неверна, если лечение, к
|
||||
> которому я тебя подталкиваю, лечит не то, если ты видишь второе место того же
|
||||
> класса, о котором я не сказал, — **скажи прямо и поступи правильно, а не
|
||||
> послушно.** Пустой ответ («разобрал, возражений нет») тоже честен и
|
||||
> принимается.
|
||||
|
||||
🔴 **Не требуй возражения — требуй разбора.**
|
||||
|
||||
🔑 **Урожайность смены 5: четверо из четырёх помощников поспорили с заданием по
|
||||
существу, и все четверо были правы.** Трижды они сделали **сильнее**, чем я
|
||||
просил: пол в модели вместо искалки по тексту; безопасный способ показать
|
||||
красноту вместо опасного, который я сам же и назвал опасным; пометка отдельным
|
||||
пунктом без коробочки вместо врезки, которую читатель пропустит.
|
||||
|
||||
---
|
||||
|
||||
## 0.5. Список запрещённых файлов — писать в каждое задание
|
||||
|
||||
`lefthook.yml` · `.squawk.toml` · `app/deptrac.yaml` · `app/phpstan*` ·
|
||||
`app/vitest.config.ts` · `app/package*.json` · `app/composer.json` · `.claude/`
|
||||
· `cspell-words.txt` · `.markdownlint*` · `docs/observer/**` ·
|
||||
`docs/superpowers/plans/**` · `docs/superpowers/specs/**` · `docs/grilling/**` ·
|
||||
чужие приёмочные листы · файлы соседних кругов поимённо.
|
||||
|
||||
🔴🔴 **И отдельно — файлы ТРЁХ соседних смен.**
|
||||
|
||||
**Реклама:** `AdvertisingWalletController.php`, `AdvertisingCampaignController.php`,
|
||||
`OpisanieDvizheniyaKoshelka.php`, `OtchyotKoshelka.php`, `RaskryitieZamorozki.php`,
|
||||
`advertising.ts`, `AdWalletView.vue`, `CampaignList.vue`, `AppSidebar.vue`,
|
||||
`AppMoreDrawer.vue`, `IncidentsWatchFailures.php`, `Koshelyok*`,
|
||||
`app/tests/Feature/Advertising/**`.
|
||||
|
||||
**Телеграм клиента:** всё под `app/app/Services/ClientTg/**`,
|
||||
`app/app/Http/Controllers/Api/ClientTg/**`, `app/app/Models/ClientTg/**`,
|
||||
`TgRobotController.php`, `PrichinaDlyaKlienta.php`,
|
||||
`app/tests/Feature/ClientTg/**`, `telegram*.spec.ts`.
|
||||
|
||||
**Лендинг:** появилась четвёртая смена — коммиты про лендинг легли в ту же
|
||||
ветку 06.08. Файлы соберёшь перечнем.
|
||||
|
||||
🔴 **Список собери заново командой.** Годный прибор — не `git status`, а §4.0 п. 5.
|
||||
|
||||
---
|
||||
|
||||
## 0.6. Скилы — инвокация ПЕРВОЙ, жёсткое правило
|
||||
|
||||
Правила Claude §12: задача попадает под карту — **сначала вызвать скил, потом
|
||||
работать**. Понадобятся `superpowers:subagent-driven-development`,
|
||||
`superpowers:test-driven-development`, `superpowers:verification-before-completion`.
|
||||
|
||||
---
|
||||
|
||||
## 0.7. 🔴 ПРОГОНЫ — ЧЕМ И КАК, ИНАЧЕ ДАТЧИК СОВРЁТ
|
||||
|
||||
```bash
|
||||
cd app
|
||||
DB_DATABASE=<своя_база> php -d memory_limit=-1 vendor/bin/pest # полный
|
||||
DB_DATABASE=<своя_база> php -d memory_limit=-1 vendor/bin/pest --filter=Obzvon
|
||||
```
|
||||
|
||||
🔴🔴 **`php artisan test` для полного прогона НЕГОДЕН** — замерено сменой 5
|
||||
дважды: падает по нехватке памяти с выходом **255**. `-d memory_limit` до
|
||||
дочернего процесса **не доезжает**.
|
||||
|
||||
🪤 **`composer test`** режет по 300 секунд. 🪤 **Вывод не через трубу** — код
|
||||
возврата придёт от трубы.
|
||||
|
||||
🔴 **Датчик — число проверок, а не код возврата** (правило 21). И проверь
|
||||
арифметику: `зелёных + красных + ошибок + пропущено = всего`.
|
||||
|
||||
🔴 **Своя база на КАЖДЫЙ прогон** (правило 22). Заняты и живы на 06.08:
|
||||
`liderra_testing_stroyka5`, `liderra_testing_stroyka5b`,
|
||||
`liderra_testing_nadzor`, плюс десятки чужих `liderra_testing_*`.
|
||||
|
||||
```bash
|
||||
cd app
|
||||
export PGPASSWORD=$(grep '^DB_PASSWORD=' .env | cut -d= -f2- | tr -d '\r"')
|
||||
"/c/Program Files/PostgreSQL/16/bin/psql" -U postgres -h 127.0.0.1 -p 5432 -d postgres -c "CREATE DATABASE liderra_testing_<своё>"
|
||||
DB_DATABASE=liderra_testing_<своё> php artisan migrate --force
|
||||
```
|
||||
|
||||
Датчик, что база твоя: число записей в `migrations` = числу файлов в
|
||||
`app/database/migrations/`.
|
||||
|
||||
🔴 **Проверки экрана — отдельный прогон** и там **есть чужое красное**:
|
||||
`npx vitest run tests/Frontend/KoshelyokZakladkiPoKanalam.spec.ts` даёт
|
||||
**5 красных**. Замерено четырьмя приборами, что это **не наше**: их экраны
|
||||
никто не правит, наш коммит рекламы не касался, а проверка
|
||||
`KoshelyokZakladkiPoKanalamTest.php` **удалена из ветки их собственным
|
||||
коммитом** «две колонки, период, разрез по кампаниям». Им стоит сказать.
|
||||
|
||||
---
|
||||
|
||||
## 0.8. Говорить с владельцем
|
||||
|
||||
Простым русским, **без программистских слов** — он не программист. Не называть
|
||||
сделанным непроверенное. **Свою ошибку называть первой.** Упёрся в стену —
|
||||
остановиться и спросить, обход не искать.
|
||||
|
||||
🆕 🔴 **И выносить развилку с полным набором дорог** (правило 24).
|
||||
|
||||
---
|
||||
|
||||
## 0.9. 🔴 БУМАГИ — ЧЕТВЁРТАЯ ОБЯЗАННОСТЬ
|
||||
|
||||
**Свой приёмочный лист.** Заводится **ДО** работы:
|
||||
|
||||
```
|
||||
docs/superpowers/priyomka/stroyka-6/<задача>-<тема>-ГГГГ-ММ-ДД.md
|
||||
```
|
||||
|
||||
🔴 **Заведи именно `stroyka-6/`.** Образцы формы — четыре листа смены 5 в
|
||||
`priyomka/stroyka-5/`, самый полный **`z-2-4-status-obzvona-v-sdelke-2026-08-06.md`**:
|
||||
в нём есть всё, что должно быть — свои ошибки первыми, разведка до задания,
|
||||
«что обязуюсь замерить и какой ответ считаю годным» написанное **до** работы,
|
||||
допущения защиты названные **заранее**, приговор и возражения помощника с
|
||||
исходом каждого.
|
||||
|
||||
**Что обновляется в ТОМ ЖЕ круге:**
|
||||
|
||||
| бумага | что дописать |
|
||||
|---|---|
|
||||
| **план** | врезка `> ✅ **ПОСТРОЕНО ДД.ММ.ГГГГ** — коммит …` + ссылка на лист + 🔴 всё, чем **замер разошёлся с текстом задачи** |
|
||||
| **свой лист** | приговор: своя ошибка первой, возражения помощников, свои ножи, четыре мерки, вырезание второго рода |
|
||||
| **протокол решений** | новое решение владельца — с отрезанными ветками и строкой «чего это НЕ закрывает». 🔴 Только если владелец решил **вслух** |
|
||||
|
||||
**Датчики в конце круга:**
|
||||
|
||||
```bash
|
||||
grep -c 'ПОСТРОЕНО' docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md
|
||||
grep -c -E '^#+ .*З-[0-9]+\.[0-9]+' docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md
|
||||
grep -c -E '^(🟡 )?\*\*Т[0-9]+' docs/superpowers/specs/2026-08-04-obzvon-pod-klienta.md
|
||||
grep -c '^### Р' docs/grilling/2026-08-03-metodika-lena-pod-klienta.md
|
||||
```
|
||||
|
||||
Первое число обязано равняться числу закрытых задач. Сейчас **16 при 16**.
|
||||
|
||||
🪤 **Крючок перед коммитом НЕ проверяет разметку и правописание в
|
||||
`docs/superpowers/**`** — гоняй руками:
|
||||
|
||||
```bash
|
||||
node node_modules/markdownlint-cli2/markdownlint-cli2-bin.mjs "<твои файлы>"
|
||||
```
|
||||
|
||||
🔴 **Правописание там слепо совсем**: отвечает «файлов: 0» и **выглядит
|
||||
успехом**. Обход — временная копия в корне хранилища, проверить, убрать,
|
||||
убедиться командой. 🪤 И ещё: пока в разметке есть обрамлённый кусок кода,
|
||||
проверка перестаёт пропускать имена в одиночных значках **по всему файлу** —
|
||||
замерено вырезанием сменой 5.
|
||||
|
||||
🔴 **В конце смены** — переписать этот промт для следующей: числа собрать
|
||||
заново командой, свои коммиты перечислить поимённо, порядок работ переставить.
|
||||
|
||||
---
|
||||
|
||||
## 1. ЧИТАТЬ В ЭТОМ ПОРЯДКЕ
|
||||
|
||||
1. 🔴 **Метод** (§0.1) — целиком, 519 строк.
|
||||
2. 🔴 **Этот файл.**
|
||||
3. 🔴 [plans/2026-08-04-obzvon-pod-klienta.md](plans/2026-08-04-obzvon-pod-klienta.md)
|
||||
— план, **53 задачи в семи волнах** плюс лист выката. Читать целиком.
|
||||
4. 🔴 **Приёмочный лист `priyomka/stroyka-5/z-2-4-status-obzvona-v-sdelke-2026-08-06.md`**
|
||||
— самый полный образец формы. **Обязателен.**
|
||||
5. **Прочие листы смены 5** — `z-0-9-…`, `z-0-11-…`, `z-0-10-…` плюс четыре
|
||||
отчёта помощников рядом.
|
||||
6. [specs/2026-08-04-obzvon-pod-klienta.md](specs/2026-08-04-obzvon-pod-klienta.md)
|
||||
— спека, **112 требований**. Полноту меряют **от спеки**.
|
||||
7. [../grilling/2026-08-03-metodika-lena-pod-klienta.md](../grilling/2026-08-03-metodika-lena-pod-klienta.md)
|
||||
— **121 решение владельца. Переоткрывать нельзя.** 🆕 Р116–Р121 приняты
|
||||
06.08.2026, разбор в §5.1.
|
||||
8. [2026-08-08-PROMT-obzvon-stroyka-2.md](2026-08-08-PROMT-obzvon-stroyka-2.md)
|
||||
— промт смены 5, там устройство робота и доступ на сервер.
|
||||
|
||||
---
|
||||
|
||||
## 2. ЧИСЛА — СОБРАНЫ КОМАНДОЙ 06.08.2026 ПОЗДНИМ ВЕЧЕРОМ
|
||||
|
||||
🔴 **Собери заново на входе.** Ветка уезжает под ногами: в ней работают
|
||||
**четыре** смены.
|
||||
|
||||
| что | сколько | чем мерил |
|
||||
|---|---|---|
|
||||
| ветка | `feat/prospects-manual-testing-kp` | `git rev-parse --abbrev-ref HEAD` |
|
||||
| вершина | 🪤 **не записана намеренно** — протухает от любого чужого коммита | `git rev-parse --short HEAD` |
|
||||
| план | **53 задачи, 16 отмечено ПОСТРОЕНО** | §0.9 |
|
||||
| спека | **112 требований** | §0.9 |
|
||||
| протокол решений | **121** 🆕 владелец закрыл шесть 06.08 | §0.9 |
|
||||
| приёмочных листов | 8 + 4 + 6 + 2 + **8** в `stroyka-1…5` | `ls … \| wc -l` |
|
||||
| полный прогон в конце смены 5 | **5166 проверок, 5162 зелёных, 4 пропущено, красных 0, выход 0** | `php -d memory_limit=-1 vendor/bin/pest` |
|
||||
| **обзвон и сделки — твоя настоящая мерка** | было 214/214, стало **447 из 446** | `--filter=Obzvon` и `--filter=Deals` |
|
||||
| проверки экрана | 🔴 **5 красных, чужие** | §0.7 |
|
||||
|
||||
**Коммиты смены 5 — поимённо, их 12** (кода в них четыре):
|
||||
|
||||
```
|
||||
808928458 f2f16a70a 3d0955ce4 c84b8051b e8008ac22
|
||||
588f05aef a5bd891d0 dbf268088 525af778b 1ed7a0128 fdbff6e2d
|
||||
bde377a11 16fdbc985 b0581e0bf df8c4720c 6d97a9691 6e775055
|
||||
```
|
||||
|
||||
🔴 **Чужие ложатся ПРЯМО МЕЖДУ твоими** — за смену 5 соседи положили коммиты по
|
||||
рекламе, телеграму и лендингу. ⇒ **вершина ветки не признак твоей работы**.
|
||||
|
||||
🪤 **Отбор коммитов «по автору» НЕ РАБОТАЕТ** — все смены коммитят под одним
|
||||
именем.
|
||||
|
||||
---
|
||||
|
||||
## 3. ЧТО СДЕЛАНО — НЕ ПЕРЕДЕЛЫВАЙ
|
||||
|
||||
**Готово 16 задач из 53. Осталось 37.** Всё в ветке, **не пушено**, на боевом
|
||||
модуля нет.
|
||||
|
||||
| волна | готово | осталось |
|
||||
|---|---|---|
|
||||
| **0. Шов, перевод, предохранители** | **4** из 11 — З-0.2, З-0.8, З-0.9, З-0.11 | 7 |
|
||||
| **1. Деньги** | **7** из 9 | 2 — З-1.6, З-1.7 |
|
||||
| **2. Данные человека** | ✅ **5 из 5 — ВОЛНА ЗАКРЫТА** | 0 |
|
||||
| **3. Наружу к человеку** | 0 из 8 | 8 |
|
||||
| **4. Скрипты и экраны** | 0 из 9 | 9 |
|
||||
| **5. Авто-обзвон** | 0 из 3 | 3 |
|
||||
| **6. Отделка** | 0 из 8 | 8 |
|
||||
|
||||
🔴🔴 **ГЛАВНОЕ, ЧЕГО НЕ ВИДНО ПО ЧИСЛАМ: ПОЗВОНИТЬ МОДУЛЬ ПО-ПРЕЖНЕМУ НЕ МОЖЕТ
|
||||
НИ РАЗУ.** Шва портал↔робот нет (З-0.3, З-0.6), перевода на менеджера нет
|
||||
(З-0.4). Построено всё **вокруг** звонка. Сам звонок — нет.
|
||||
|
||||
🪤 **И теперь обманчивость сильнее прежнего.** Закрыты две самые дешёвые волны —
|
||||
деньги и данные человека: они живут внутри портала, и их видно проверками.
|
||||
Волна 0 и волна 3 — то, что уходит к живому человеку, — почти нетронуты, а там
|
||||
отыграть нельзя: **набранный номер уже набран**.
|
||||
|
||||
🔴 **Две из тридцати семи брать нельзя** — решения владельца: **З-0.1** только
|
||||
пробами в отдельное окно (Р96, Р101), **З-1.7** письмо общее с рекламой, не
|
||||
трогаем до починки дороги назад (Р86).
|
||||
|
||||
### 3.1. 🆕 Что построила смена 5
|
||||
|
||||
| задача | коммиты | что построено |
|
||||
|---|---|---|
|
||||
| **З-0.9** заготовки робота | `588f05aef` `a5bd891d0` | 43 файла оснастки в хранилище, постоянная копия на машине, правило возврата при загрузке, сторож на **23** проверки с **тремя** замками против записи чужого голоса |
|
||||
| **З-0.11** правда про флаг | `525af778b` `1ed7a0128` `fdbff6e2d` | **14 пометок-замеров в 5 бумагах**, плюс контрольный лист аудита закона |
|
||||
| **З-2.4** статус обзвона в сделке | `bde377a11` `df8c4720c` `6e775055` | раскладка исходов настройкой, дверь записи, **пол в модели**, свой построитель против записи мимо двери, блок в карточке, пометки касаний |
|
||||
|
||||
### 3.2. 🔴 З-0.10 ПОСТРОЕНА, НО ПРИГОВОР ЕЙ НЕ ВЫНЕСЕН — ПЕРВОЕ ДЕЛО СМЕНЫ 6
|
||||
|
||||
Помощник закончил её на самом краю смены. Коммит **`67195f0b`**, 17 файлов,
|
||||
2216 строк добавлено, чужого не затащено — это я проверил. **Полной приёмки
|
||||
надзиратель провести не успел.**
|
||||
|
||||
**Что успел замерить я сам:** коммит в ветке · всё в своих папках · четыре
|
||||
сторожа машины зелены и **не уменьшились** — 26 / 46 / 27 / 23.
|
||||
|
||||
🔴 **Чего НЕ проверял и обязана проверить смена 6** — по уже заведённому листу
|
||||
`priyomka/stroyka-5/z-0-10-storozha-mashiny-2026-08-06.md`, там десять обещанных
|
||||
замеров и пять допущений для вырезания:
|
||||
|
||||
1. **свой полный прогон** на своей базе. Помощник докладывает **5161 проверку,
|
||||
одну красную и говорит, что она чужая** — от соседнего помощника. 🔴 Это
|
||||
надо **перемерить своей рукой**: сегодня надзиратель уже принял чужую
|
||||
красноту за чужую, а она оказалась его собственной (ошибка 46);
|
||||
2. 🔴 **вырезание второго рода своим ножом** — не тем, каким резал он;
|
||||
3. **проверку 3 задачи — «итог протух, письмо ушло, даже если внутри зелено»** —
|
||||
годный замер тут только один: **подставить старую дату и увидеть письмо**, а
|
||||
не прочитать код.
|
||||
|
||||
🔑 **Что он нашёл сверх задания — читать обязательно:**
|
||||
|
||||
- 🔴 **«На машине живут три сторожа» — было неверно, и это меняло устройство.**
|
||||
Замерено: три из четырёх живут **в хранилище** и ходят на машину по ssh с
|
||||
рабочего места; на машине лежал только один. Просто «завести расписание» было
|
||||
нельзя — своего ключа у машины нет, а заводить ключ от самой себя на машине с
|
||||
голосами живых людей он **отказался**, и правильно;
|
||||
- 🔴 **тайное слово в адресе — НЕ годная защита**: адреса целиком текут в журналы
|
||||
веб-сервера. Взял подпись тела. ⇒ **Р116 в этой части исполнен иначе, чем
|
||||
записано владельцем, и это надо ему сказать**;
|
||||
- 🔴 **он сам себе завёл новое место для чужой речи** — и соседний сторож это
|
||||
честно поймал. Разобрал, журнал теперь обнуляется каждым обходом;
|
||||
- 🔴 **один его сторож был ложно зелёным**: разница во времени у используемой
|
||||
библиотеки **знаковая**, и «меньше двух минут» проходило **всегда**. Под ножом
|
||||
оставался зелёным. Переписан;
|
||||
- 🔴 **обратные кавычки в `bots/lena-golos/README.md` не защищают слово вообще** —
|
||||
дефект настройки правописания. Чинить владельцу.
|
||||
|
||||
🔴 **Незакрытое, названное им самим:** портальная половина **не выкачена**, и
|
||||
его же сторож честно краснеет одной проверкой из 42 — он и есть датчик
|
||||
завершённости выката; «письмо уйдёт» ничем не закрыто — умри почта портала,
|
||||
замолчит сам присмотр; подделка свежего зелёного итога односторонним каналом не
|
||||
ловится.
|
||||
|
||||
---
|
||||
|
||||
## 4. ЧТО ДЕЛАТЬ — ПОРЯДОК РАБОТ
|
||||
|
||||
### 4.0. На входе, до первого круга
|
||||
|
||||
1. **Цело ли хозяйство:**
|
||||
|
||||
```bash
|
||||
M=node_modules; miss=0; tot=0
|
||||
for d in $M/*/; do n=$(basename "$d"); case "$n" in @*) continue;; esac
|
||||
tot=$((tot+1)); [ -f "$d/package.json" ] || miss=$((miss+1)); done
|
||||
for d in $M/@*/*/; do tot=$((tot+1)); [ -f "$d/package.json" ] || miss=$((miss+1)); done
|
||||
echo "пакетов: $tot, разодрано: $miss"
|
||||
```
|
||||
|
||||
На 06.08: **957 пакетов, разодран 1** — чужой мусор `ffmpeg-static`, пустая
|
||||
папка, в списке зависимостей его нет, никто не зовёт. Не наше.
|
||||
|
||||
2. 🔴 **Показать сторожей текста КРАСНЫМИ на подставной ошибке**, а не считать
|
||||
папки. Замерено сменой 5: правописание отвечает «файлов: 0» на путях вне
|
||||
хранилища и **выглядит успехом**.
|
||||
3. **Полный прогон** на своей базе — точка отсчёта. §0.7.
|
||||
4. **Проверить, проходит ли коммит:** `git ls-files -u` — не пусто значит стена.
|
||||
5. 🔴 **Прогнать четырёх сторожей машины робота:**
|
||||
|
||||
```bash
|
||||
bash bots/lena-golos/proverka-raspisaniya.sh # 26 проверок
|
||||
bash bots/lena-golos/proverka-tekstovogo-sleda.sh # 46
|
||||
bash bots/lena-golos/proverka-chistki.sh # 27
|
||||
bash bots/lena-golos/proverka-zagotovok.sh # 23 🆕 смена 5
|
||||
```
|
||||
|
||||
На 06.08 все четыре зелёные. 🔴 **Красный тут — не «сломался тест», а
|
||||
«уборка мертва на живой машине».**
|
||||
6. 🔴🔴 **ПЕРЕЧЕНЬ ЖИВЫХ ЧУЖИХ ПРАВОК — ГЛАВНЫЙ ПРИБОР ЭТОЙ ВЕТКИ.** Гонять **и
|
||||
перед кругом, и перед приговором**.
|
||||
|
||||
```bash
|
||||
git status --porcelain -- app/ | awk '{print $2}' | while read f; do
|
||||
[ -f "$f" ] || continue
|
||||
a=$(git show HEAD:"$f" 2>/dev/null | tr -d '\r' | git hash-object --stdin 2>/dev/null)
|
||||
b=$(tr -d '\r' < "$f" | git hash-object --stdin)
|
||||
[ "$a" != "$b" ] && echo "ЖИВАЯ: $f"
|
||||
done
|
||||
```
|
||||
|
||||
### 4.1. 🔴 ПОРЯДОК РАБОТ
|
||||
|
||||
| # | что берёшь | почему здесь |
|
||||
|---|---|---|
|
||||
| **1** | 🔴 **З-0.10 — принять или доделать** | работа лежит несохранённой в дереве и пропадёт при первом чужом откате. См. §3.2 |
|
||||
| **2** | **З-0.3 и З-0.6 — шов портал↔робот** | 🔴 **без него модуль не звонит вовсе.** Всё построенное вокруг звонка ждёт именно этого |
|
||||
| **3** | **З-0.4 перевод на менеджера** | вторая половина звонка |
|
||||
| **4** | **З-3.1 рамка звонка по часам** | 🔴🔴 **не бери, не прочитав Р115** — там ловушка, которая выглядит правильной, §5.2 |
|
||||
| **5** | **З-1.6 суточная сверка** | остаток денежной волны |
|
||||
| **6** | **пробы З-0.1** — только в отдельное окно (Р96, Р101) | 🔴 сервер делит железо с боевым рендером |
|
||||
|
||||
🔴 **З-1.7 не брать** — Р86.
|
||||
|
||||
---
|
||||
|
||||
## 5. РЕШЕНИЯ ВЛАДЕЛЬЦА
|
||||
|
||||
**Переспрашивать нельзя.** Полный текст с отрезанными ветками — в протоколе.
|
||||
Р84–Р115 — в промте смены 5, §5. Здесь только новые.
|
||||
|
||||
### 5.1. 🆕 Р116–Р121 — владелец закрыл шесть развилок 06.08.2026
|
||||
|
||||
| # | решение | что делаешь ты |
|
||||
|---|---|---|
|
||||
| **Р116** | итог сторожей **машина сама стучит в портал**, тайным словом в адресе; порталу ключ от машины не даём | устройство З-0.10 |
|
||||
| **Р117** | письмо о беде — на `ops@liderra.ru` **через настройку**, не зашитое в код | 🔴 личная почта в чужом боевом коде **остаётся**, её не трогаем |
|
||||
| **Р118** | контрольный лист аудита закона **помечаем тем же замером** | ✅ сделано. Запрет на `.claude/` снят **точечно, только для той правки** |
|
||||
| **Р119** | пять чужих слов **дописываем в общий словарь**, чужой текст не правим | ✅ сделано. 🔴 Разрешение **однократное**; правило «словарь не трогаем» остаётся по умолчанию |
|
||||
| **Р120** | разговором считается контакт **от одной секунды** | ✅ в настройке. Взял и сразу скинул — это **контакт**, он виден строкой |
|
||||
| **Р121** | в карточке показываем **ВСЕ** прошлые списки | ✅ в настройке |
|
||||
|
||||
🔑 **Урок Р119, который дороже самого решения.** После решения владельца
|
||||
помощник нашёл **четвёртую дорогу, лучше всех трёх предложенных**: указание
|
||||
сторожу **внутри самого файла**. У неё нет ни одной из двух цен общего словаря.
|
||||
⇒ **способ на будущее надо выбрать один**, и указание в файле выглядит
|
||||
правильнее. Это ошибка 43 надзирателя, а не помощника.
|
||||
|
||||
### 5.2. 🔴🔴 ДВЕ ЛОВУШКИ, КОТОРЫЕ ВЫГЛЯДЯТ ПРАВИЛЬНЫМИ
|
||||
|
||||
**Ловушка Р115 — в З-3.1, впереди.** Рамка обзвона это **пересечение двух окон
|
||||
в разных часах**: 10:00–20:00 по месту получателя и график менеджера по часам
|
||||
клиента. Построенное правило `SmsQuietHours::canSendNow()` берёт **одну** пару
|
||||
границ. Кто «добавит часы менеджера», просто сузив их, получит: менеджер из
|
||||
Москвы 14:00–18:00 будет прочитан как 14:00–18:00 **по Владивостоку** — робот
|
||||
переведёт звонок на **спящего менеджера**. Экран покажет «звоним 14–18», журнал
|
||||
покажет «звонок в 14:07», и всё сойдётся.
|
||||
|
||||
**Ловушка `attempt_outcome` — в волне 3, впереди.** У `obzvon_calls` четыре
|
||||
значения исхода **в одном столбце**, ровно как было у итога по номеру. Замок в
|
||||
базе сторожит **список значений, а не смысл**. Смена 5 закрыла родную ловушку у
|
||||
итога по номеру тремя половинами защиты; **у попытки не закрыто ничего**.
|
||||
🔴 Лечить надо **вместе с тем, кто эти значения пишет**, а пишет их будущий
|
||||
приём отчёта робота — то есть твоя работа.
|
||||
|
||||
---
|
||||
|
||||
## 6. ГРАБЛИ — ОПЛАЧЕНЫ, НЕ НАСТУПАЙ ЗАНОВО
|
||||
|
||||
### Про приборы и замеры
|
||||
|
||||
- 🆕 🔴🔴 **Датчик прогона — число проверок, а не код возврата.** Правило 21.
|
||||
- 🆕 🔴🔴 **Сходится ли арифметика**: `зелёных + красных + ошибок + пропущено =
|
||||
всего`. Помощник смены 5 нашёл так 44 падения при `"провалов": 0`.
|
||||
- 🆕 🔴 **Своя база на КАЖДЫЙ прогон.** Правило 22.
|
||||
- 🆕 🔴 **`sha256sum` на Windows приписывает к имени звёздочку**, а на машине
|
||||
нет — сравнение даёт ложное расхождение.
|
||||
- 🆕 🔴 **Сырое сравнение с содержимым коммита врёт**: рабочая копия хранит
|
||||
лишний невидимый знак в **каждой** строке. Годно только со снятием `\r` или
|
||||
через `git diff`.
|
||||
- 🔴 **Датчик, проверенный на здоровом случае, не проверен вовсе.**
|
||||
- 🔴🔴 **Воспроизводи тем же способом, каким это происходит в жизни.**
|
||||
- 🔴 **`grep`, не нашедший совпадений, возвращает код 1** и рвёт цепочку.
|
||||
- 🔴 **Код возврата после трубы приходит ОТ ТРУБЫ.**
|
||||
- 🔴🔴 **`grep -i` здесь НЕ складывает регистр кириллицы — никаким ключом.**
|
||||
- 🔴 **`find … -size -1k` — негодный прибор пустого файла.**
|
||||
- 🔴🔴 **Образец поиска, дающий один ответ с частью и без неё, — сломан.**
|
||||
- 🔴🔴 **`git status` — негодный датчик «дерево чисто».**
|
||||
- 🔴 **Приговор «этого нет» — только вторым прибором.**
|
||||
- 🔴 **На боевом `LOG_LEVEL=warning`** — всё ниже предупреждения не доезжает.
|
||||
|
||||
### Про сторожей
|
||||
|
||||
- 🆕 🔴🔴 **Запись ловят НА ЗАПИСИ, а не в тексте.** Смена 5: сторож по тексту
|
||||
не видел правку через связь — там нет ни имени класса, ни имени таблицы.
|
||||
Годно — пол в самой модели плюс свой построитель.
|
||||
- 🆕 🔴 **Список «под защитой» считай ВЫЧИТАНИЕМ, а не перечислением.** Тогда
|
||||
новое значение, о котором забыли, окажется **под** защитой, а не мимо неё.
|
||||
- 🆕 🔴 **Сторож с белым списком сам становится дверью.** Исключение допустимо
|
||||
одно и по устройству — сам защищаемый вход.
|
||||
- 🔴🔴 **Проверка, ищущая подстроку по всему файлу, удовлетворяется собственным
|
||||
комментарием.** Смена 5 напоролась снова: зацепка по ключу покраснела бы **на
|
||||
самой двери**, где этот ключ написан в пояснении как образец запрещённого.
|
||||
Лечение — убирать комментарии **разбором языка**, а не вычёркиванием.
|
||||
- 🔴 **Мерка «в пределах N строк» даёт ложное зелёное.**
|
||||
|
||||
### Про бумаги
|
||||
|
||||
- 🆕 🔴 **Пометка после таблицы не работает вовсе** — таблицу читают строками и
|
||||
до низа не доходят. Ставить **там, где читатель входит**.
|
||||
- 🆕 🔴 **Пометка должна называть команду, которой её перемеряют** — иначе она
|
||||
сама станет ложью, когда защиту построят.
|
||||
- 🆕 🪤 **Пометка без коробочки невидима для того, кто собирает из листа только
|
||||
коробочки.** Замерено: 45 пунктов с коробочкой, замер в них — **ноль**.
|
||||
- 🪤 **Числа в шапке плана протухают молча.**
|
||||
|
||||
### Про помощников
|
||||
|
||||
- 🔑 **«Разбери задание» — самый урожайный приём.** Смена 5: четверо из
|
||||
четырёх, и трижды сделали **сильнее**, чем просили.
|
||||
- 🔑 **Самое ценное несогласие приходит как ОТКАЗ ИСПОЛНЯТЬ СПОСОБ.**
|
||||
- 🆕 🔴 **Помощник, назвавший свою ошибку сам, ценнее безошибочного.** Смена 5:
|
||||
трое из четырёх назвали свои беды первыми, и одна из них — 152 чужие строки в
|
||||
своём коммите — иначе не всплыла бы вовсе.
|
||||
- 🪤 Субагенты с git — **Sonnet или Opus**, не Haiku.
|
||||
|
||||
### Про git и соседей
|
||||
|
||||
- 🆕 🔴 **«Коммит поимённо» не защищает от захвата чужих СТРОК в общем файле.**
|
||||
Правило 23.
|
||||
- 🆕 🔴 **Приговор о чужой потере — только вторым замером.** Смена 5: помощник
|
||||
верно доложил, что чужой коммит ушёл с ветки, — а к моменту приёмки соседи
|
||||
уже переложили работу заново. Тревога была правдой **его момента**.
|
||||
- **Коммитить только поимённо**, сообщение в файле и **без круглых скобок**,
|
||||
состав перечитать через `git show --stat`.
|
||||
|
||||
### Про прод и базу
|
||||
|
||||
- 🔴 **`crm_app_user` не может создавать таблицы.**
|
||||
- 🔴 **Новым таблицам с защитой по строкам мало политики и прав** — ПЕРЕзапускать
|
||||
`db/03_service_bypass_policies.sql`.
|
||||
- 🔴 **Канон схемы разбит надвое:** `db/schema.sql` исполняется первой
|
||||
миграцией, таблицы дельта-миграций — в `db/schema_modules.sql`.
|
||||
- 🪤 **`db/**` сверять со снятием `\r`.**
|
||||
|
||||
---
|
||||
|
||||
## 7. ВОПРОСЫ, ВИСЯЩИЕ НА ВЛАДЕЛЬЦЕ
|
||||
|
||||
✅ **Шесть закрыты 06.08.2026** — Р116–Р121.
|
||||
|
||||
**Осталось:**
|
||||
|
||||
1. **Р92 — метки маскирования ПДн для таблиц обзвона.** Отложено владельцем;
|
||||
вернуть **до первого живого звонка**. Звонка не было — вопрос стоит.
|
||||
2. 🔴 **По каким словам робот считает, что его попросили не звонить** (Р114).
|
||||
«Не звоните мне больше» он поймёт, а «слушайте, хватит уже» — вопрос.
|
||||
**Трогает З-3.2.**
|
||||
3. 🔴 **Попадает ли в список отказов исход «агрессия»** (Р114). Человек,
|
||||
сказавший «это незаконно, откуда мой номер», от звонков не отказывался.
|
||||
4. 🔴 **Свободное место на машине портала не замерено** (Р105). Мерить — значит
|
||||
идти на боевой, а туда без слова владельца нельзя.
|
||||
5. **Входит ли хранение звука в цену звонка** или это общий расход.
|
||||
6. 🆕 **Нужен ли замок в самой базе** против записи исхода мимо двери. Смена 5
|
||||
закрыла три дороги из четырёх; прямой SQL мимо портала не закрыт ничем, и
|
||||
без замка в базе полной защиты нет.
|
||||
7. 🆕 **Читает ли кто-нибудь `ops@liderra.ru`** (Р117) — писем туда до сих пор
|
||||
не приходило ни одного.
|
||||
8. 🆕 **Как часто слать письмо при затяжной беде** (Р117) — письмо каждый день
|
||||
об одной беде перестают читать через неделю.
|
||||
9. 🆕 **Итог обзвона привязан к списку, а не к человеку** (Р121): если номер
|
||||
сменил владельца, карточка сольёт историю двух разных людей. Не обсуждали.
|
||||
|
||||
🔴 **И одно, что решением НЕ закрыто, хотя выглядит закрытым.** Р114 велит
|
||||
роботу самому заносить отказника в список — но **связь «исход отказ → запись в
|
||||
списке» никто не строит**. Человек, сказавший «не звоните», попадёт в список,
|
||||
только если это построят. Это главная защита, и её пока нет.
|
||||
|
||||
---
|
||||
|
||||
## 8. ГРАНИЦЫ
|
||||
|
||||
- 🔴 **Боевой прод liderra.ru — живые клиенты и деньги.** Не трогать; база по
|
||||
умолчанию только чтение.
|
||||
- 🔴 **Сервер робота разрешён (Р84), но делит машину с рендером боевого
|
||||
«Поиска клиентов».** 🔴🔴 **Машину НЕ ПЕРЕЗАГРУЖАТЬ** — это уронит работу
|
||||
живых клиентов. Проверки, требующие перезагрузки, заменяй и **говори вслух,
|
||||
чего замена не доказывает**.
|
||||
- 🔴 **Ничего не удалять на сервере робота** — Р94.
|
||||
- **Не пушить и не выкатывать** без слова владельца.
|
||||
- **В ветке работают четыре смены.** Чужие ветки не трогать; чужой конфликт не
|
||||
разрешать.
|
||||
- **Телефоны и слова людей в бумагах — маской.** Чужие записи и ПДн — только в
|
||||
`моя/` и `golos-test/`, обе в игноре.
|
||||
- **Открытые вопросы владельца не закрывать** без явного «закрываем».
|
||||
|
||||
**СТИЛЬ:** простой русский, без программистских слов — владелец не программист.
|
||||
**Не называть сделанным непроверенное. Свою ошибку называть первой.**
|
||||
**И работать строго по методу — это прямое требование владельца.**
|
||||
@@ -0,0 +1,772 @@
|
||||
# ПРОМТ смене 7 — «Обзвон»: шов построен с обеих сторон, а звонка всё ещё нет
|
||||
|
||||
**Написан 07.08.2026, в конце смены 6.** Смена 6 была **стройкой**: шесть кругов,
|
||||
три задачи закрыты, одна закрыта наполовину честно, две доделки. Владелец закрыл
|
||||
**шесть** развилок — Р122–Р127. И **в плане нашлась дыра**: целого куска работы там
|
||||
не было вовсе.
|
||||
|
||||
🔴🔴🔴 **ПЕРВОЕ ДЕЙСТВИЕ — ПРОЧЕСТЬ МЕТОД ЦЕЛИКОМ. Не пересказ, не «я помню».**
|
||||
519 строк, в нашей ветке его нет:
|
||||
|
||||
```bash
|
||||
git show 5cb09ae7e:docs/superpowers/METOD-vedeniya-fichi-v1.md
|
||||
```
|
||||
|
||||
**Владелец требует этого прямым словом каждую смену.** Правила стройки одним
|
||||
списком — §0.0, и они не советы.
|
||||
|
||||
🔴 **Главное, что изменилось — семь вещей:**
|
||||
|
||||
1. 🔴🔴 **В ПЛАНЕ НЕ ХВАТАЛО ЦЕЛОГО КУСКА, и это нашёл помощник, а не я.** Между
|
||||
«робот принял задание» и «робот отчитался» лежит **сам набор номера** — его не
|
||||
было **ни в одной из 53 задач**. Заведена **З-0.12**, задач стало **54**.
|
||||
2. ✅ **Шов портал↔робот построен с ОБЕИХ сторон** — З-0.3 и половина З-0.6.
|
||||
🔴 **И всё равно позвонить модуль не может:** между половинами дыра из п. 1.
|
||||
3. 🟢 **Уборка записей чужих голосов перестала зависеть от человеческой памяти —
|
||||
замерено живьём.** 07.08 в 02:20:01 UTC машина обошла своих сторожей **сама**,
|
||||
без человека, за 2 секунды.
|
||||
4. 🔴 **Правка ЖИВОГО боевого кода пошла в дело.** Правило часов рассылки СМС
|
||||
(З-3.1) и дверь боевого «Поиска клиентов» (Р126). Оба **не выкачены**.
|
||||
5. 🆕 **Владелец закрыл шесть развилок — Р122–Р127.** Разбор в §5.1.
|
||||
6. 🔴 **Ошибок надзирателя стало 58** — смена 6 прибавила **одиннадцать**, и снова
|
||||
**все одного класса**: прибор мерил не то, о чём его спрашивали. Правил стало
|
||||
**30**.
|
||||
7. 🔴 **Твоя мерка выросла: полный прогон 5242 при 5166 на входе смены 6.**
|
||||
|
||||
---
|
||||
|
||||
## 0.0. 🔴🔴🔴 ПРАВИЛА СТРОЙКИ — ПРОЧТИ ДО ТОГО, КАК ТРОНЕШЬ ХОТЬ ОДИН ФАЙЛ
|
||||
|
||||
**Кто ты.** Надзиратель. 🔴 **Своей рукой кода не пишешь.** Четыре обязанности:
|
||||
**задания помощникам · приборы · приговор своей рукой · бумаги.** Единственное
|
||||
исключение — диагностический прибор, живущий **вне хранилища**.
|
||||
|
||||
🔑 **Почему так.** Тот, кто пишет код, защищает свой код. Надзиратель,
|
||||
взявшийся чинить сам, теряет способность выносить приговор.
|
||||
|
||||
**Как идёт круг — семь шагов, ни один не пропускается:**
|
||||
|
||||
1. **Один круг = ОДНА задача плана.**
|
||||
2. 🔴 **Завести приёмочный лист ДО работы.** Смена 6 нарушила это один раз из
|
||||
шести и записала нарушение первой строкой листа. Куда класть — §0.9.
|
||||
3. **Задание помощнику переписать ЦЕЛИКОМ.** Он не видел твоего разговора.
|
||||
Проверки плана перенести **дословно**.
|
||||
4. **В задание — просьба о разборе** (§0.4, дословно) и **полный список
|
||||
запрещённых файлов** (§0.5).
|
||||
5. **Резать СВОИМ ножом.** Сторож обязан быть показан **красным** — и не тем
|
||||
ножом, каким резал исполнитель. Плюс **вырезание второго рода**: назови
|
||||
вслух допущение защиты и найди годный по форме случай **мимо** него.
|
||||
6. **Всё вырезанное вернуть и доказать слепком** — 🪤 **со снятием невидимых
|
||||
знаков конца строки**, иначе прибор соврёт.
|
||||
7. **Приговор своей рукой** в лист, и **бумаги обновить в том же круге**.
|
||||
|
||||
**Семь правил, которые нарушают чаще всего:**
|
||||
|
||||
- 🔴 **«Отчёт помощника — не доказательство. И отчёт судьи тоже.»**
|
||||
- 🔴 **Свою ошибку называешь ПЕРВОЙ**, до находок. Всегда.
|
||||
- 🔴 **Всякий перечень собирается КОМАНДОЙ, а не глазами.**
|
||||
- 🆕 🔴🔴 **И «собрал командой» — ещё не «собрал верно».** Спроси у прибора,
|
||||
**что именно он считает**. Оплачено ошибкой 55: я насчитал двенадцать зовущих
|
||||
там, где их пять, — команда считала **упоминания**, а я назвал их вызовами, и
|
||||
построил на этом главный довод задания.
|
||||
- 🔴 **Приговор «этого нет» — только вторым прибором.** «Ноль» — самый опасный
|
||||
ответ прибора.
|
||||
- 🔴 **«Построено» и «работает» — разные утверждения.** Спрашивай третье:
|
||||
**доехало ли до жизни.**
|
||||
- 🔴 **Развилку владельцу выносят с ПОЛНЫМ набором дорог.**
|
||||
|
||||
**Чего не делаешь:**
|
||||
|
||||
- не переписываешь спеку, план и протокол — они закрыты до тебя. 🔴 **Врезки
|
||||
ПОСТРОЕНО и новые задачи по решению владельца — можно и нужно**, остальное нет;
|
||||
- не переоткрываешь решения владельца Р1–Р127;
|
||||
- не пушишь и не выкатываешь без его слова;
|
||||
- не трогаешь файлы соседних смен (§0.5) и не разрешаешь чужие столкновения;
|
||||
- 🔴 **не правишь файлы через оболочку** — только средствами правки. Нарушено
|
||||
**четырьмя сменами подряд**.
|
||||
|
||||
---
|
||||
|
||||
## 0.1. Метод — прочесть ЦЕЛИКОМ, до первого действия
|
||||
|
||||
```bash
|
||||
git show 5cb09ae7e:docs/superpowers/METOD-vedeniya-fichi-v1.md
|
||||
```
|
||||
|
||||
🔴 **Не пересказ, не выжимка, не «я помню» — целиком, 519 строк.**
|
||||
|
||||
---
|
||||
|
||||
## 0.2. 🔴 ЛИСТ ОБЯЗАННОСТЕЙ — ПРОЙТИ ПОИМЁННО НА КАЖДОМ КРУГЕ
|
||||
|
||||
| # | обязанность | чем доказывается |
|
||||
|---|---|---|
|
||||
| 1 | **круг = ОДНА задача плана** | не две, не половина |
|
||||
| 2 | 🔴 **лист заведён ДО работы** | смена 6 нарушила это один раз — см. §0.3 ошибка 53 |
|
||||
| 3 | **задание переписано ЦЕЛИКОМ** | помощник не знает ничего |
|
||||
| 4 | **проверки плана перенесены ДОСЛОВНО** | потерянная оговорка отменяет решение владельца |
|
||||
| 5 | **список запрещённых файлов ПОЛНЫЙ** | §0.5 |
|
||||
| 6 | **просьба о разборе — дословно** | §0.4, самый урожайный приём |
|
||||
| 7 | **первым действием помощника — файл отчёта со скелетом** | «пиши по ходу» — условие. Одного помощника смены 6 оборвало посреди работы, и всё несохранённое сгорело |
|
||||
| 8 | 🔴 **сторож показан КРАСНЫМ, и режешь СВОИМ ножом** | не тем, каким резал исполнитель |
|
||||
| 9 | 🔴 **возврат доказан слепком СО СНЯТИЕМ `\r`** | сырое сравнение на Windows врёт |
|
||||
| 10 | 🔴 **полный прогон СВОЕЙ рукой** | на **своей отдельной базе**, §0.7 |
|
||||
| 11 | **две строки про соседей** | дословно |
|
||||
| 12 | **зовущие поимённо** — глазами, не числом | 🆕 и спроси у прибора, что он считает |
|
||||
| 13 | 🔴 **вырезание ВТОРОГО рода** | назови допущение и найди случай мимо |
|
||||
| 14 | **приговор записан в приёмочный лист** | |
|
||||
| 15 | **между кругами не останавливаешься** | владельца спрашивают на стене или границе |
|
||||
| 16 | 🔴 **бумаги обновлены В ТОМ ЖЕ КРУГЕ** | план, лист, протокол |
|
||||
|
||||
🔑 **Что вырезание второго рода дало смене 6 — четыре круга из шести:**
|
||||
|
||||
- 🔴 **З-0.10:** дверь принимала итог **зелёным**, когда машина в том же теле
|
||||
прямо говорила «двое сторожей красные». Длина списка нигде не сверялась с
|
||||
объявленным числом;
|
||||
- 🔴 **З-0.6:** тот же знак с **другим телефоном** приёмник считал повтором,
|
||||
**молча не звонил второму человеку** и отдавал порталу номер звонка первого;
|
||||
- 🔴 **З-0.3:** тайный ключ уезжал **открытым текстом**, если адрес записан без
|
||||
шифрования. Отсюда решение владельца Р123;
|
||||
- 🟢 **З-3.1:** нож **не пробил** работу — и это тоже результат. Арифметика
|
||||
пересечения окон сошлась знак в знак на каждом часе суток.
|
||||
|
||||
---
|
||||
|
||||
## 0.3. 🔴 ПЯТЬДЕСЯТ ВОСЕМЬ ОШИБОК НАДЗИРАТЕЛЯ — ВСЕ ОДНОГО КЛАССА
|
||||
|
||||
Класс у всех один: **утверждение без замера**.
|
||||
|
||||
**Ошибки 1–47** (смены 1–5) — сжато: не открыл план и сказал «такого нет» ·
|
||||
пересказал проверку и потерял оговорку · передал чужой отчёт как факт · собрал
|
||||
список глазами · предложил приём, не запустив его · правил файл командой
|
||||
оболочки вопреки прямому запрету · **вынес владельцу развилку с неполным набором
|
||||
дорог** · запустил два прогона на одной базе и чуть не списал свою красноту на
|
||||
соседей · запустил команду, которую оболочка не приняла, и получил код возврата
|
||||
ноль.
|
||||
|
||||
**Ошибки 48–58 — смена 6, все одиннадцать мои:**
|
||||
|
||||
| # | что сделал | чего не сделал | чем кончилось |
|
||||
|---|---|---|---|
|
||||
| 48 | написал в сообщение своего коммита, что файлы задачи лежат несохранёнными | **не перечитал сообщение** после того, как замерил обратное | в истории осталось ложное утверждение о состоянии работы |
|
||||
| 49 | дал своей находке довод «подделывать итог стало проще» | не проверил довод на злоумышленнике | помощник разбил: **порог для подделки не поднялся ни на сколько** |
|
||||
| 50 | 🔴 велел считать «200 и мусор» **отказом** | не сверил со своим же пунктом выше | задание **противоречило себе**; буквальное исполнение вернуло бы дыру — **человеку позвонили бы дважды** |
|
||||
| 51 | резал своим ножом семь случаев подряд | не изолировал случаи | подставной робот **протёк** из третьего в четвёртый и пятый, два замера оказались ложью |
|
||||
| 52 | оставил в бумаге посторонний иероглиф | проверил образцом `[一-鿿]` | образец **пометил весь файл подряд** — негодный прибор |
|
||||
| 53 | 🔴 отправил круг | **не завёл приёмочный лист ДО работы** | лист, написанный после, подгоняется под то, что вышло. Ровно на этом месте метод и рассыпается |
|
||||
| 54 | полез считать записи в памяти знаков | **предположил**, что она лежит текстовым файлом | она база SQLite. Прибор упал — и это была удача: посчитай он молча ноль, я объявил бы, что память не чистится |
|
||||
| 55 | 🔴🔴 написал в задание «правило зовут двенадцать файлов» | не спросил у прибора, **что он считает** | зовущих **пять**; двенадцать — упоминания. **Главный довод задания был ложным целиком** |
|
||||
| 56 | резал своим ножом новый код | **выдумал подписи** вместо того, чтобы взять их из файла | прибор упал на первом же случае |
|
||||
| 57 | 🔴 подсказал, как отличить «свою машину» | не проверил подсказку своим же ножом | подсказка дала бы **зелёных сторожей при живой дыре** — помощник показал замером |
|
||||
| 58 | 🔴🔴 назвал в задании **одну** дверь с тайным ключом | не спросил прибор, **сколько их** | дверей оказалось **три**. Почини помощник только названную — решение владельца осталось бы невыполненным, а дыра открытой |
|
||||
|
||||
🔑 **Ошибка 55 стоит отдельного взгляда.** Правило «всякий перечень собирать
|
||||
командой» я **соблюл**: собрал командой. Но команда искала **строку**, а я
|
||||
назвал найденное **зовущими** — и построил на этом главный довод задания.
|
||||
Правило соблюдено по букве и нарушено по существу.
|
||||
|
||||
### 🔑 ТРИДЦАТЬ ПРАВИЛ. Двадцать четыре перенесены, шесть добыты сменой 6
|
||||
|
||||
1. **Проверки плана переносить в задание ДОСЛОВНО.**
|
||||
2. **Всё, что план утверждает о коде, сверять командой ДО задания.**
|
||||
3. 🔴 **Прежде чем сказать «такого нет» — ОТКРОЙ и посмотри.**
|
||||
4. **Мерить и тогда, когда чужой отчёт говорит, что ты неправ.**
|
||||
5. 🔴 **Команду, которую вписываешь в бумагу, — ЗАПУСТИ.**
|
||||
6. 🔴 **Список запрещённых файлов писать ПОЛНЫМ.**
|
||||
7. **В каждое задание — просьба о разборе.**
|
||||
8. 🔴 **Своя ошибка называется ПЕРВОЙ.**
|
||||
9. 🔴 **Бумаги проверять командой так же, как код.**
|
||||
10. 🔴🔴 **Разведка обязана покрывать и ТВОИ СОБСТВЕННЫЕ дописки к заданию.**
|
||||
11. 🔴🔴 **ВСЯКИЙ ПЕРЕЧЕНЬ СОБИРАТЬ КОМАНДОЙ, А НЕ ГЛАЗАМИ.**
|
||||
12. 🔴🔴 **Приём, который ты СОВЕТУЕШЬ помощнику, меряй строже своего.**
|
||||
13. 🔴🔴 **«ПОСТРОЕНО» И «РАБОТАЕТ» — РАЗНЫЕ УТВЕРЖДЕНИЯ.**
|
||||
14. 🔴🔴 **ПРИБОР МЕРИТ ТО, ЧЕМ ТЫ ЕГО ЗАПУСТИЛ, А НЕ ТО, О ЧЁМ СПРАШИВАЕШЬ.**
|
||||
15. 🔴🔴 **СПОСОБ ЛЕЧЕНИЯ, ВЗЯТЫЙ У СОСЕДА, ПРОВЕРЯЙ НА СВОЁМ ПРЕДМЕТЕ.**
|
||||
16. 🔴 **СЧЁТ КОММИТОВ НАЗАД — НЕГОДНЫЙ ПРИБОР В ОБЩЕЙ ВЕТКЕ.**
|
||||
17. 🔴🔴 **«ПРИРОСТ РАВЕН ЧИСЛУ СТОРОЖЕЙ» — НЕ ДАТЧИК на движущемся дереве.**
|
||||
18. 🔴🔴 **ПРИБОР ОБЯЗАН УМЕТЬ ТО, ЧЕГО ТЫ ОТ НЕГО ХОЧЕШЬ — ЗАПУСТИ ЕГО НА
|
||||
ПОДСТАВНОЙ СТРОКЕ.**
|
||||
19. 🔴🔴 **ЧУЖОЙ КОММЕНТАРИЙ О ЗАЩИТЕ — НЕ ЗАМЕР, А ОБЕЩАНИЕ.**
|
||||
20. 🔴🔴 **«ПРОГОН КРАСНЫЙ, ПРИЧИНА НЕИЗВЕСТНА» — часто значит, что ты задал не
|
||||
тот вопрос.** Годный: **кто ещё работает в этом дереве прямо сейчас.**
|
||||
21. 🔴🔴 **ДАТЧИК ПРОГОНА — НЕ КОД ВОЗВРАТА, А ЧИСЛО ПРОВЕРОК В ВЫВОДЕ.**
|
||||
И проверяй **арифметику**: `зелёных + красных + ошибок + пропущено = всего`.
|
||||
🔑 Смена 6: **двое помощников** нашли так своих красных сторожей, спрятавшихся
|
||||
в поле «ошибок» при «провалов: 0». Правило работает у разных людей на разных
|
||||
предметах.
|
||||
22. 🔴🔴 **СВОЯ БАЗА НА ПРОГОН, А НЕ НА СМЕНУ.** 🆕 И **одна проба за раз на
|
||||
одной базе**: помощник смены 6 запустил вторую во время полного прогона и
|
||||
получил **52 небылицы подряд**.
|
||||
23. 🔴 **«КОММИТ ПОИМЁННО» ЗАЩИЩАЕТ ОТ ЗАХВАТА ЧУЖИХ ФАЙЛОВ, НО НЕ ЧУЖИХ СТРОК
|
||||
ВНУТРИ ОБЩЕГО ФАЙЛА.**
|
||||
24. 🔴🔴 **РАЗВИЛКУ ВЛАДЕЛЬЦУ ВЫНОСЯТ С ПОЛНЫМ НАБОРОМ ДОРОГ.**
|
||||
25. 🆕 🔴 **ПОПРАВИЛ БУМАГУ — ПЕРЕЧИТАЙ И ТО, ЧТО УЖЕ УШЛО В СООБЩЕНИЕ КОММИТА.**
|
||||
Оплачено ошибкой 48: замер, сделанный после написания сообщения, до сообщения
|
||||
**не доезжает**, и в историю уходит ложь.
|
||||
26. 🆕 🔴🔴 **«СОБРАЛ КОМАНДОЙ» — ЕЩЁ НЕ «СОБРАЛ ВЕРНО». СПРОСИ У ПРИБОРА, ЧТО
|
||||
ИМЕННО ОН СЧИТАЕТ: УПОМИНАНИЯ ИЛИ ВЫЗОВЫ.** Оплачено ошибкой 55.
|
||||
27. 🆕 🔴🔴 **ПОДСКАЗКУ, КОТОРУЮ ТЫ ДАЁШЬ В ЗАДАНИИ, ПРОВЕРЯЙ СВОИМ ЖЕ НОЖОМ.**
|
||||
Оплачено ошибкой 57: моя подсказка дала бы **зелёных сторожей при живой
|
||||
дыре**. Правило 12 говорит «меряй строже своего» — а это его острая половина:
|
||||
**прогони подсказку через собственные случаи, прежде чем писать её в задание.**
|
||||
28. 🆕 🔴 **КАЖДЫЙ СЛУЧАЙ НОЖА — ОТДЕЛЬНЫМ ЗАПУСКОМ.** Оплачено ошибкой 51:
|
||||
подставной собеседник из одного случая **протекает** в следующие, и два
|
||||
замера подряд оказываются ложью, выглядя осмысленно.
|
||||
29. 🆕 🔴🔴 **ПРЕЖДЕ ЧЕМ НАЗВАТЬ МЕСТО БЕДЫ В ЗАДАНИИ — ЗАМЕРЬ, СКОЛЬКО МЕСТ
|
||||
ПОЛЬЗУЕТСЯ ТЕМ ЖЕ КЛЮЧОМ ИЛИ ТОЙ ЖЕ НАСТРОЙКОЙ.** Оплачено ошибкой 58: я
|
||||
назвал одну дверь, их оказалось **три**. Почини помощник только названную —
|
||||
решение владельца осталось бы невыполненным, а дыра открытой. **Одно
|
||||
найденное место — это находка, а не перечень.**
|
||||
30. 🆕 🔴 **НЕ ЗАПУСКАЙ ПРОГОН, ПОКА НЕ ЗАКОНЧИЛ ПРАВИТЬ ФАЙЛЫ.** Помощник смены 6
|
||||
запустил полный прогон, и через четыре минуты форматтер переписал два его
|
||||
файла **прямо во время прогона**. Зелёный цвет такого прогона не доказывает
|
||||
ничего: он мерил то, что лежало на диске в **момент чтения**.
|
||||
|
||||
---
|
||||
|
||||
## 0.4. Просьба о разборе — вставлять в КАЖДОЕ задание дословно
|
||||
|
||||
> Это задание писал я, и я **регулярно вкладываю в него собственные ошибки**.
|
||||
> Разбери задание по существу. Если моя картина беды неверна, если лечение, к
|
||||
> которому я тебя подталкиваю, лечит не то, если ты видишь второе место того же
|
||||
> класса, о котором я не сказал, — **скажи прямо и поступи правильно, а не
|
||||
> послушно.** Пустой ответ («разобрал, возражений нет») тоже честен и
|
||||
> принимается.
|
||||
|
||||
🔴 **Не требуй возражения — требуй разбора.**
|
||||
|
||||
🔑 **Урожайность смены 6 — выше, чем у смены 5.** Помощники **разбили три моих
|
||||
замера подряд**, нашли **дыру в самом плане**, показали, что моя **подсказка**
|
||||
дала бы негодную починку, и один нашёл **вторую такую же дыру в живом боевом
|
||||
модуле**, которого его никто не просил трогать.
|
||||
|
||||
🔑 **И трое из шести назвали свою ошибку первыми** — в том числе такую, которую
|
||||
иначе не нашёл бы никто: **сторож читал собственное пояснение вместо кода** и
|
||||
оставался зелёным при сломанном сравнении ключей.
|
||||
|
||||
---
|
||||
|
||||
## 0.5. Список запрещённых файлов — писать в каждое задание
|
||||
|
||||
`lefthook.yml` · `.squawk.toml` · `app/deptrac.yaml` · `app/phpstan*` ·
|
||||
`app/vitest.config.ts` · `app/package*.json` · `app/composer.json` · `.claude/`
|
||||
· `cspell-words.txt` · `.markdownlint*` · `docs/observer/**` ·
|
||||
`docs/superpowers/plans/**` · `docs/superpowers/specs/**` · `docs/grilling/**` ·
|
||||
`db/**` · `app/database/migrations/**` · чужие приёмочные листы · файлы соседних
|
||||
кругов поимённо.
|
||||
|
||||
🔴🔴 **И отдельно — файлы ТРЁХ соседних смен.**
|
||||
|
||||
**Реклама:** `AdvertisingWalletController.php`, `AdvertisingCampaignController.php`,
|
||||
`OpisanieDvizheniyaKoshelka.php`, `OtchyotKoshelka.php`, `RaskryitieZamorozki.php`,
|
||||
`advertising.ts`, `AdWalletView.vue`, `CampaignList.vue`, `AppSidebar.vue`,
|
||||
`AppMoreDrawer.vue`, `IncidentsWatchFailures.php`, `Koshelyok*`,
|
||||
`app/tests/Feature/Advertising/**`.
|
||||
|
||||
**Телеграм клиента:** всё под `app/app/Services/ClientTg/**`,
|
||||
`app/app/Http/Controllers/Api/ClientTg/**`, `app/app/Models/ClientTg/**`,
|
||||
`TgRobotController.php`, `PrichinaDlyaKlienta.php`,
|
||||
`app/tests/Feature/ClientTg/**`, `telegram*.spec.ts`.
|
||||
|
||||
**Лендинг:** коммиты про лендинг ложатся в ту же ветку. Файлы собери перечнем.
|
||||
|
||||
🪤 **Чужой мусор в дереве, не трогать и в коммит не брать:**
|
||||
`app/proba-korenj.md` (одна строка), `app/echo` (чья-то опечатка перенаправления),
|
||||
`bots/lena-golos/__pycache__/` (остаток от прогона пробы; сносить мне среда не
|
||||
дала — оставлено намеренно).
|
||||
|
||||
🔴 **Список собери заново командой.** Годный прибор — не `git status`, а §4.0 п. 6.
|
||||
|
||||
---
|
||||
|
||||
## 0.6. Скилы — инвокация ПЕРВОЙ, жёсткое правило
|
||||
|
||||
Правила Claude §12: задача попадает под карту — **сначала вызвать скил, потом
|
||||
работать**. Понадобятся `superpowers:subagent-driven-development`,
|
||||
`superpowers:test-driven-development`, `superpowers:verification-before-completion`.
|
||||
|
||||
---
|
||||
|
||||
## 0.7. 🔴 ПРОГОНЫ — ЧЕМ И КАК, ИНАЧЕ ДАТЧИК СОВРЁТ
|
||||
|
||||
```bash
|
||||
cd app
|
||||
DB_DATABASE=<своя_база> php -d memory_limit=-1 vendor/bin/pest # полный
|
||||
DB_DATABASE=<своя_база> php -d memory_limit=-1 vendor/bin/pest --filter=Obzvon
|
||||
```
|
||||
|
||||
🔴🔴 **`php artisan test` для полного прогона НЕГОДЕН** — падает по нехватке
|
||||
памяти с выходом **255**, а обёртка среды печатает «выход 0».
|
||||
|
||||
🪤 **`composer test`** режет по 300 секунд. 🪤 **Вывод не через трубу** — код
|
||||
возврата придёт от трубы.
|
||||
|
||||
🔴 **Датчик — число проверок, а не код возврата** (правило 21). И проверь
|
||||
арифметику: `зелёных + красных + ошибок + пропущено = всего`.
|
||||
|
||||
🔴 **Своя база на КАЖДЫЙ прогон, и одна проба за раз** (правило 22). Заняты и
|
||||
живы на 07.08: `liderra_testing_nadzor6a`, `nadzor6b`, `nadzor6c`, `nadzor6d`,
|
||||
`z03`, `z03b`, `z06`, `z06priem`, `z06body`, `z010fix`, `r123`, `z31`, `render`,
|
||||
`znak`, `znak2`, `stroyka5`, `stroyka5b`, плюс десятки чужих `liderra_testing_*`.
|
||||
|
||||
```bash
|
||||
cd app
|
||||
export PGPASSWORD=$(grep '^DB_PASSWORD=' .env | cut -d= -f2- | tr -d '\r"')
|
||||
"/c/Program Files/PostgreSQL/16/bin/psql" -U postgres -h 127.0.0.1 -p 5432 -d postgres -c "CREATE DATABASE liderra_testing_<своё>"
|
||||
DB_DATABASE=liderra_testing_<своё> php artisan migrate --force
|
||||
```
|
||||
|
||||
Датчик, что база твоя: число записей в `migrations` = числу файлов в
|
||||
`app/database/migrations/`.
|
||||
|
||||
🆕 **Проверки робота — отдельно, они на питоне и без обвязки:**
|
||||
|
||||
```bash
|
||||
python bots/lena-golos/proba-priyomnika.py # 108 проверок
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 0.8. Говорить с владельцем
|
||||
|
||||
Простым русским, **без программистских слов** — он не программист. Не называть
|
||||
сделанным непроверенное. **Свою ошибку называть первой.** Упёрся в стену —
|
||||
остановиться и спросить, обход не искать. **Развилку выносить с полным набором
|
||||
дорог** (правило 24).
|
||||
|
||||
🆕 🔑 **Что сработало у смены 6:** перед развилкой — **разведка у самого
|
||||
предмета**. Прежде чем спросить «требовать ли шифрование», я сходил на машину
|
||||
робота и замерил, что шифрованный вход у неё **уже работает**. Это убрало из
|
||||
разговора целую ложную дорогу.
|
||||
|
||||
---
|
||||
|
||||
## 0.9. 🔴 БУМАГИ — ЧЕТВЁРТАЯ ОБЯЗАННОСТЬ
|
||||
|
||||
**Свой приёмочный лист.** Заводится **ДО** работы:
|
||||
|
||||
```
|
||||
docs/superpowers/priyomka/stroyka-7/<задача>-<тема>-ГГГГ-ММ-ДД.md
|
||||
```
|
||||
|
||||
🔴 **Заведи именно `stroyka-7/`.** Образцы формы — листы смены 6 в
|
||||
`priyomka/stroyka-6/`. Самый полный — **`z-0-3-portalnaya-storona-shva-2026-08-06.md`**.
|
||||
Самый поучительный — **`z-0-6-priyomnik-zadaniy-2026-08-07.md`**: он начинается
|
||||
с раздела «нарушение, которое называю до всего остального».
|
||||
|
||||
**Что обновляется в ТОМ ЖЕ круге:**
|
||||
|
||||
| бумага | что дописать |
|
||||
|---|---|
|
||||
| **план** | врезка `> ✅ **ПОСТРОЕНО ДД.ММ.ГГГГ** — коммит …` + ссылка на лист + 🔴 всё, чем **замер разошёлся с текстом задачи** |
|
||||
| **свой лист** | приговор: своя ошибка первой, возражения помощников, свои ножи, четыре мерки, вырезание второго рода |
|
||||
| **протокол решений** | новое решение владельца — с отрезанными ветками и строкой «чего это НЕ закрывает». 🔴 Только если владелец решил **вслух** |
|
||||
|
||||
**Датчики в конце круга:**
|
||||
|
||||
```bash
|
||||
grep -c 'ПОСТРОЕНО' docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md
|
||||
grep -c -E '^#+ .*З-[0-9]+\.[0-9]+' docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md
|
||||
grep -c -E '^(🟡 )?\*\*Т[0-9]+' docs/superpowers/specs/2026-08-04-obzvon-pod-klienta.md
|
||||
grep -c '^### Р' docs/grilling/2026-08-03-metodika-lena-pod-klienta.md
|
||||
```
|
||||
|
||||
Первое число обязано равняться числу закрытых задач. Сейчас **19 при 54**.
|
||||
|
||||
🪤 **Крючок перед коммитом НЕ проверяет разметку и правописание в
|
||||
`docs/superpowers/**`** — гоняй руками:
|
||||
|
||||
```bash
|
||||
node node_modules/markdownlint-cli2/markdownlint-cli2-bin.mjs "<твои файлы>"
|
||||
```
|
||||
|
||||
🔴 **Правописание там слепо совсем**: отвечает «файлов: 0» и **выглядит
|
||||
успехом**. Обход — временная копия в корне хранилища, проверить, убрать,
|
||||
убедиться командой. 🔴 **А `docs/grilling/**` крючок проверяет** — протокол
|
||||
решений гоняй через `npx cspell` перед коммитом.
|
||||
|
||||
🔴 **В конце смены** — переписать этот промт для следующей: числа собрать заново
|
||||
командой, свои коммиты перечислить поимённо, порядок работ переставить.
|
||||
|
||||
---
|
||||
|
||||
## 1. ЧИТАТЬ В ЭТОМ ПОРЯДКЕ
|
||||
|
||||
1. 🔴 **Метод** (§0.1) — целиком, 519 строк.
|
||||
2. 🔴 **Этот файл.**
|
||||
3. 🔴 [plans/2026-08-04-obzvon-pod-klienta.md](plans/2026-08-04-obzvon-pod-klienta.md)
|
||||
— план, **54 задачи в семи волнах** плюс лист выката. Читать целиком.
|
||||
4. 🔴 **Листы смены 6** в `priyomka/stroyka-6/` — три приёмочных и шесть отчётов
|
||||
помощников.
|
||||
5. [specs/2026-08-04-obzvon-pod-klienta.md](specs/2026-08-04-obzvon-pod-klienta.md)
|
||||
— спека, **112 требований**. Полноту меряют **от спеки**.
|
||||
6. [../grilling/2026-08-03-metodika-lena-pod-klienta.md](../grilling/2026-08-03-metodika-lena-pod-klienta.md)
|
||||
— **127 решений владельца. Переоткрывать нельзя.** Р122–Р127 приняты
|
||||
07.08.2026, разбор в §5.1.
|
||||
7. [2026-08-09-PROMT-obzvon-stroyka-3.md](2026-08-09-PROMT-obzvon-stroyka-3.md)
|
||||
— промт смены 6, там устройство робота, доступ на сервер и решения Р84–Р121.
|
||||
|
||||
---
|
||||
|
||||
## 2. ЧИСЛА — СОБРАНЫ КОМАНДОЙ 07.08.2026
|
||||
|
||||
🔴 **Собери заново на входе.** Ветка уезжает под ногами: в ней работают
|
||||
**четыре** смены, за сутки в неё легло **28** коммитов.
|
||||
|
||||
| что | сколько | чем мерил |
|
||||
|---|---|---|
|
||||
| ветка | `feat/prospects-manual-testing-kp` | `git rev-parse --abbrev-ref HEAD` |
|
||||
| вершина | 🪤 **не записана намеренно** — протухает от любого чужого коммита | |
|
||||
| план | **54 задачи, 19 отмечено ПОСТРОЕНО** | §0.9 |
|
||||
| спека | **112 требований** | §0.9 |
|
||||
| протокол решений | **127** 🆕 владелец закрыл шесть 07.08 | §0.9 |
|
||||
| полный прогон в конце смены 6 | **5242 проверки, 5238 зелёных, 4 пропущено, красных 0** | `php -d memory_limit=-1 vendor/bin/pest` |
|
||||
| проверки приёмника робота | **108, красных 0** | `python bots/lena-golos/proba-priyomnika.py` |
|
||||
| сторожа машины робота | **26 / 46 / 27 / 23**, плюс обход **42** | `bash bots/lena-golos/proverka-*.sh` |
|
||||
|
||||
**Коммиты смены 6 — поимённо, их десять:**
|
||||
|
||||
```
|
||||
c351b0b6 18f933550 872cc4147 f409f874 5dfe32dc9
|
||||
f671e21d dd3b6fb4b c7ae012f b5ea25f2 575b6d649
|
||||
```
|
||||
|
||||
🔴 **Чужие ложатся ПРЯМО МЕЖДУ твоими.** ⇒ **вершина ветки не признак твоей
|
||||
работы**. 🪤 **Отбор коммитов «по автору» НЕ РАБОТАЕТ** — все смены коммитят под
|
||||
одним именем.
|
||||
|
||||
---
|
||||
|
||||
## 3. ЧТО СДЕЛАНО — НЕ ПЕРЕДЕЛЫВАЙ
|
||||
|
||||
**Готово 19 задач из 54. Осталось 35.** Всё в ветке, **не пушено**, на боевом
|
||||
модуля нет.
|
||||
|
||||
| волна | готово | осталось |
|
||||
|---|---|---|
|
||||
| **0. Шов, перевод, предохранители** | **6** из 12 | 6 — З-0.1, З-0.4, З-0.5, З-0.6 (половина), З-0.7, **З-0.12** |
|
||||
| **1. Деньги** | **7** из 9 | 2 — З-1.6, З-1.7 |
|
||||
| **2. Данные человека** | ✅ **5 из 5 — ВОЛНА ЗАКРЫТА** | 0 |
|
||||
| **3. Наружу к человеку** | **1** из 8 | 7 |
|
||||
| **4. Скрипты и экраны** | 0 из 9 | 9 |
|
||||
| **5. Авто-обзвон** | 0 из 3 | 3 |
|
||||
| **6. Отделка** | 0 из 8 | 8 |
|
||||
|
||||
🔴🔴 **ГЛАВНОЕ, ЧЕГО НЕ ВИДНО ПО ЧИСЛАМ: ПОЗВОНИТЬ МОДУЛЬ ПО-ПРЕЖНЕМУ НЕ МОЖЕТ
|
||||
НИ РАЗУ.** И теперь ясно, где именно обрыв: **шов построен с обеих сторон, а
|
||||
между ними нет самого набора номера** (З-0.12).
|
||||
|
||||
Сегодняшняя цепочка: портал говорит «позвони» → робот **принимает задание и
|
||||
отвечает „принято"** → **дальше не происходит ничего.** Помощник нарочно оставил
|
||||
на месте набора заглушку, честно отвечающую «набор не настроен»: **приёмник
|
||||
выглядит бесполезным вместо того, чтобы выглядеть работающим.**
|
||||
|
||||
🔴 **Две из тридцати пяти брать нельзя** — решения владельца: **З-0.1** только
|
||||
пробами в отдельное окно (Р96, Р101), **З-1.7** письмо общее с рекламой, не
|
||||
трогаем до починки дороги назад (Р86).
|
||||
|
||||
### 3.1. 🆕 Что построила смена 6
|
||||
|
||||
| задача | коммиты | что построено |
|
||||
|---|---|---|
|
||||
| **З-0.10** приёмка + доделка | `c351b0b6` | приговор вынесен через сутки, дыра связности итога закрыта |
|
||||
| **З-0.3** портальная половина шва | `18f933550` `dd3b6fb4b` | клиент с тремя умениями, мягкая деградация, предел общими замками, **проверка шифрования (Р123)**, 82 сторожа |
|
||||
| **З-0.6** роботная половина, **ПОЛОВИНА** | `f409f874` `f671e21d` | приёмник заданий, память знаков на сутки (Р124), проба **108 проверок** |
|
||||
| **З-3.1** окно часов | `5dfe32dc9` | пересечение двух окон в разных часах, **правка живого модуля СМС**, 144 816 сверок «не сломали живое» |
|
||||
|
||||
### 3.2. 🔴🔴 ПЕРВОЕ ДЕЛО СМЕНЫ 7: КРУГ ОБОРВАН, ЕГО РАБОТА ЛЕЖИТ НЕСОХРАНЁННОЙ
|
||||
|
||||
Смену остановили на седьмом круге. Помощник закрывал **третью дверь** с тем же
|
||||
тайным ключом (`ProxyMarketProbe`, решение владельца принято 07.08). **Он не
|
||||
успел сохранить работу коммитом.**
|
||||
|
||||
🔴 **Её снесёт первый же чужой откат.** Файлы поимённо, замерено командой перед
|
||||
остановкой:
|
||||
|
||||
```text
|
||||
M app/app/Services/External/ProxyMarketProbe.php (+54 строки)
|
||||
M app/config/services.php (+6)
|
||||
M app/.env.example (+17)
|
||||
M app/tests/Unit/External/ProxyMarketProbeTest.php (+5)
|
||||
M app/tests/Unit/External/ExternalBalanceRefresherTest.php (+2)
|
||||
?? app/tests/Unit/External/ProxyMarketProbeDverTest.php (новый)
|
||||
?? docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-tretya-dver-2026-08-07.md (373 строки)
|
||||
```
|
||||
|
||||
**Что делать первым делом:**
|
||||
|
||||
1. **Замерить, цело ли** — сравнить с `HEAD` со снятием `\r`.
|
||||
2. **Прочесть его отчёт** — он писал по ходу, там разбор задания и, возможно,
|
||||
ответ на главный вопрос: **сколько всего дверей с этим ключом в хранилище**.
|
||||
Надзиратель нашёл три, перебирая не всё хранилище, а натыкаясь попутно.
|
||||
3. **Прогнать своей рукой и вынести приговор** — приёмочный лист **не заведён**,
|
||||
заведи его до того, как тронешь код.
|
||||
4. 🔴 **Не переделывать заново.** Работа выглядит законченной.
|
||||
|
||||
🪤 **В дереве рядом лежит и ЧУЖОЕ живое** — соседняя смена правит карточку
|
||||
клиента: `AdminTenantsController.php`, `adminDashboardHelpers.ts`,
|
||||
`AdminTenantsListActivityTest.php`. **Не трогать и в коммит не брать.**
|
||||
|
||||
---
|
||||
|
||||
### 3.3. 🔴 ЧТО ВЫКАЧЕНО НА БОЙ: НИЧЕГО. И два места этого ЖДУТ
|
||||
|
||||
1. 🔴 **Портальная половина сторожей машины не выкачена.** Машина обходит
|
||||
сторожей сама с 07.08, но её итог **никуда не уходит**: в журнале обхода
|
||||
честная строка «отправка не настроена». Сторож обхода краснеет **одной**
|
||||
проверкой из 42, и **этот красный и есть датчик завершённости выката**.
|
||||
Порядок доделки в четыре шага записан в `bots/lena-golos/README.md`.
|
||||
2. 🔴 **Приёмник заданий не выкачен**, службы на машине нет, эталон лежит в
|
||||
хранилище. И он **слушает без шифрования** — это встречная половина Р123, её
|
||||
надо закрыть при выкате.
|
||||
3. 🔴 **Правка живого модуля СМС (З-3.1) не выкачена.** Выкат отдельный и со
|
||||
словом владельца.
|
||||
4. 🔴 **Дверь боевого «Поиска клиентов» (Р126) не выкачена.**
|
||||
|
||||
---
|
||||
|
||||
## 4. ЧТО ДЕЛАТЬ — ПОРЯДОК РАБОТ
|
||||
|
||||
### 4.0. На входе, до первого круга
|
||||
|
||||
1. **Цело ли хозяйство:**
|
||||
|
||||
```bash
|
||||
M=node_modules; miss=0; tot=0
|
||||
for d in $M/*/; do n=$(basename "$d"); case "$n" in @*) continue;; esac
|
||||
tot=$((tot+1)); [ -f "$d/package.json" ] || miss=$((miss+1)); done
|
||||
for d in $M/@*/*/; do tot=$((tot+1)); [ -f "$d/package.json" ] || miss=$((miss+1)); done
|
||||
echo "пакетов: $tot, разодрано: $miss"
|
||||
```
|
||||
|
||||
На 07.08: **956 пакетов, разодрано 0**. 🪤 Мерить надо **корень**, а не
|
||||
`app/node_modules` — там своя установка на 502 пакета.
|
||||
|
||||
2. 🔴 **Показать сторожей текста КРАСНЫМИ на подставной ошибке**, а не считать
|
||||
папки. Годная проба — файл с выдуманным словом и скачком уровня заголовка:
|
||||
разметка обязана дать 1 ошибку, правописание — 5 жалоб. Замерено 07.08.
|
||||
3. **Полный прогон** на своей базе — точка отсчёта. §0.7.
|
||||
4. **Проверить, проходит ли коммит:** `git ls-files -u` — не пусто значит стена.
|
||||
5. 🔴 **Прогнать сторожей машины робота и пробу приёмника:**
|
||||
|
||||
```bash
|
||||
bash bots/lena-golos/proverka-raspisaniya.sh # 26
|
||||
bash bots/lena-golos/proverka-tekstovogo-sleda.sh # 46
|
||||
bash bots/lena-golos/proverka-chistki.sh # 27
|
||||
bash bots/lena-golos/proverka-zagotovok.sh # 23
|
||||
bash bots/lena-golos/proverka-sutochnogo-obhoda.sh # 42, одна КРАСНАЯ — см. §3.2
|
||||
python bots/lena-golos/proba-priyomnika.py # 108
|
||||
```
|
||||
|
||||
🔴 **Красный тут — не «сломался тест», а «уборка мертва на живой машине».**
|
||||
Исключение одно: одна краснота у обхода — датчик незавершённого выката.
|
||||
6. 🔴🔴 **ПЕРЕЧЕНЬ ЖИВЫХ ЧУЖИХ ПРАВОК — ГЛАВНЫЙ ПРИБОР ЭТОЙ ВЕТКИ.** Гонять **и
|
||||
перед кругом, и перед приговором**.
|
||||
|
||||
```bash
|
||||
git status --porcelain -- app/ | awk '{print $2}' | while read f; do
|
||||
[ -f "$f" ] || continue
|
||||
a=$(git show HEAD:"$f" 2>/dev/null | tr -d '\r' | git hash-object --stdin 2>/dev/null)
|
||||
b=$(tr -d '\r' < "$f" | git hash-object --stdin)
|
||||
[ "$a" != "$b" ] && echo "ЖИВАЯ: $f"
|
||||
done
|
||||
```
|
||||
|
||||
7. 🆕 **Замерить, живо ли расписание на машине робота:**
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/liderra_deploy ubuntu@51.250.1.97 'grep sobrano_v /home/ubuntu/lena-golos/otchyot-storozhey.json; date -u'
|
||||
```
|
||||
|
||||
Дата свежее суток — живо. Старее — **уборка записей чужих голосов снова
|
||||
зависит от человеческой памяти**, и это первое, что надо чинить.
|
||||
|
||||
### 4.1. 🔴 ПОРЯДОК РАБОТ
|
||||
|
||||
| # | что берёшь | почему здесь |
|
||||
|---|---|---|
|
||||
| **1** | 🔴🔴 **З-0.12 — робот вправду набирает номер** | **без неё модуль не звонит вовсе.** Шов построен с обеих сторон, между ними пусто. 🔴 Начать с замера, через кого звоним: Р81 — телефония уезжает с Манго, а всё построенное опирается на манговский ствол |
|
||||
| **2** | **З-0.5 — предел разговора 60 минут** | она разблокирует `most.py` для второй половины З-0.6 |
|
||||
| **3** | **вторая половина З-0.6 — отчёт робота о результате** | два плеча врозь, исход каждой попытки |
|
||||
| **4** | **З-0.4 — перевод на менеджера** | 🔴 план требует, чтобы З-0.1 и З-0.2 были закрыты и слиты; З-0.1 закрыта быть не может (только пробы, Р96/Р101) — **это надо вынести владельцу** |
|
||||
| **5** | **З-3.2 — свой список «не звонить»** | 🔴 и там же построить связь «исход отказ → запись в списке», которой **не строит никто** |
|
||||
| **6** | **З-1.6 суточная сверка** | 🟡 помечена «ждёт подтверждения», Т64 сверх протокола — **спросить владельца до круга** |
|
||||
| **7** | **выкаты** — четыре штуки из §3.2, каждый со словом владельца | |
|
||||
|
||||
🔴 **З-1.7 не брать** — Р86. 🔴 **З-0.1 — только пробы в отдельное окно.**
|
||||
|
||||
---
|
||||
|
||||
## 5. РЕШЕНИЯ ВЛАДЕЛЬЦА
|
||||
|
||||
**Переспрашивать нельзя.** Полный текст с отрезанными ветками — в протоколе.
|
||||
Р84–Р121 — в промте смены 6. Здесь только новые.
|
||||
|
||||
### 5.1. 🆕 Р122–Р127 — владелец закрыл шесть развилок 07.08.2026
|
||||
|
||||
| # | решение | что делаешь ты |
|
||||
|---|---|---|
|
||||
| **Р122** | машина стучится в портал **подписью письма**, а не тайным словом в адресе | ✅ сделано. Адреса целиком текут в журналы веб-сервера — слово туда попадать не должно |
|
||||
| **Р123** | к роботу **только по шифрованной дороге**, кроме своей машины | ✅ сделано. 🔴 Встречная половина: **приёмник на машине слушает без шифрования** — закрыть при выкате |
|
||||
| **Р124** | робот помнит опознавательные знаки заданий **сутки** | ✅ сделано. 🔴 Следствие: **правило счёта попыток** ещё не назначено, и неверный счёт отменит всю защиту |
|
||||
| **Р125** | номер, у которого **общего окна нет вовсе**, не набираем и говорим клиенту **до запуска** | 🔴 **не построено**: считать такие номера некому, это работа экрана запуска |
|
||||
| **Р126** | дверь боевого «Поиска клиентов» чинится **отдельным заходом** | правка живого модуля, **выкат отдельно и со словом владельца** |
|
||||
| **Р127** | восьмёрку приводим к семёрке **только у одиннадцатизначных** номеров | иначе один человек, записанный `8 999…` и `+7 999…`, получит **два звонка**. Правило узкое намеренно: слияние двух **разных** людей невозможно |
|
||||
|
||||
### 5.2. 🔴🔴 ЛОВУШКИ, КОТОРЫЕ ВЫГЛЯДЯТ ПРАВИЛЬНЫМИ
|
||||
|
||||
**Ловушка `attempt_outcome` — в волне 3, впереди.** У `obzvon_calls` четыре
|
||||
значения исхода **в одном столбце**, ровно как было у итога по номеру. Замок в
|
||||
базе сторожит **список значений, а не смысл**. Смена 5 закрыла родную ловушку у
|
||||
итога по номеру тремя половинами защиты; **у попытки не закрыто ничего.**
|
||||
🔴 Лечить надо **вместе с тем, кто эти значения пишет**, а пишет их вторая
|
||||
половина З-0.6 — то есть твоя работа.
|
||||
|
||||
🆕 **Ловушка «сторож читает пояснение, а не код».** Сработала у смены 6 **дважды,
|
||||
на двух языках**: проверка искала слово по тексту файла и находила его в
|
||||
собственном комментарии — сторож оставался **зелёным при сломанном коде**. Годно
|
||||
только смотреть в **работу**: разбор языка, скомпилированный код, живой прогон.
|
||||
|
||||
🆕 **Ловушка «зелёный сторож, который физически не может покраснеть».** Смена 6
|
||||
поймала три: проверка журнала, падавшая в поле «ошибок» вместо «провалов»;
|
||||
счётчик отправленных запросов, не видящий оборванных; и сравнение времени, у
|
||||
которого разница **знаковая**, отчего «меньше двух минут» проходило всегда.
|
||||
|
||||
---
|
||||
|
||||
## 6. ГРАБЛИ — ОПЛАЧЕНЫ, НЕ НАСТУПАЙ ЗАНОВО
|
||||
|
||||
### Про приборы и замеры
|
||||
|
||||
- 🔴🔴 **Датчик прогона — число проверок, а не код возврата.** Правило 21.
|
||||
- 🔴🔴 **Сходится ли арифметика**: `зелёных + красных + ошибок + пропущено = всего`.
|
||||
- 🆕 🔴🔴 **Спроси у прибора, что он считает: упоминания или вызовы.** Правило 26.
|
||||
- 🆕 🔴 **Каждый случай ножа — отдельным запуском.** Правило 28.
|
||||
- 🆕 🔴 **Подписи нового кода бери из файла, а не из головы.** Ошибка 56.
|
||||
- 🆕 🔴 **Устройство чужой памяти/хранилища — посмотри, а не предположи.** Ошибка 54.
|
||||
- 🔴 **`sha256sum` на Windows приписывает к имени звёздочку**, а на машине нет.
|
||||
- 🔴 **Сырое сравнение с содержимым коммита врёт** — годно только со снятием `\r`.
|
||||
- 🔴 **Датчик, проверенный на здоровом случае, не проверен вовсе.**
|
||||
- 🔴 **`grep`, не нашедший совпадений, возвращает код 1** и рвёт цепочку.
|
||||
- 🔴 **Код возврата после трубы приходит ОТ ТРУБЫ.**
|
||||
- 🔴🔴 **`grep -i` здесь НЕ складывает регистр кириллицы — никаким ключом.**
|
||||
- 🔴🔴 **`git status` — негодный датчик «дерево чисто».**
|
||||
- 🔴 **На боевом `LOG_LEVEL=warning`** — всё ниже предупреждения не доезжает.
|
||||
|
||||
### Про сторожей
|
||||
|
||||
- 🆕 🔴🔴 **Сторож, ищущий слово по тексту файла, находит его в собственном
|
||||
пояснении.** Смена 6 — дважды.
|
||||
- 🔴🔴 **Запись ловят НА ЗАПИСИ, а не в тексте.**
|
||||
- 🔴 **Список «под защитой» считай ВЫЧИТАНИЕМ, а не перечислением.**
|
||||
- 🔴 **Сторож с белым списком сам становится дверью.**
|
||||
- 🆕 🔴 **Строгость к незнакомому полю — защита с ценой.** Прибавят в чужой файл
|
||||
безобидное поле — письмо пойдёт **каждый день**, а это воюет с требованием «не
|
||||
слать письмо при здоровье». Цену называть **там, где её увидят**.
|
||||
|
||||
### Про бумаги
|
||||
|
||||
- 🆕 🔴 **Поправил бумагу — перечитай сообщение коммита.** Правило 25.
|
||||
- 🔴 **Пометка после таблицы не работает вовсе** — ставить там, где читатель входит.
|
||||
- 🔴 **Пометка должна называть команду, которой её перемеряют.**
|
||||
- 🪤 **Числа в шапке плана протухают молча.**
|
||||
|
||||
### Про помощников
|
||||
|
||||
- 🔑 **«Разбери задание» — самый урожайный приём.** Смена 6: разбили три моих
|
||||
замера, нашли дыру в плане, показали негодность моей подсказки.
|
||||
- 🔑 **Самое ценное несогласие приходит как ОТКАЗ ИСПОЛНЯТЬ СПОСОБ.**
|
||||
- 🔴 **Помощник, назвавший свою ошибку сам, ценнее безошибочного.**
|
||||
- 🆕 🪤 **Помощника может оборвать посреди работы** — беда среды, не его вина.
|
||||
Смена 6: одного оборвало, в дереве остался только скелет отчёта. ⇒ **«пиши в
|
||||
отчёт по ходу» — не совет, а условие**, и перезапуская круг, **неси в новое
|
||||
задание то, что он успел сказать**.
|
||||
- 🪤 Субагенты с git — **Sonnet или Opus**, не Haiku.
|
||||
|
||||
### Про git и соседей
|
||||
|
||||
- 🔴 **«Коммит поимённо» не защищает от захвата чужих СТРОК в общем файле.**
|
||||
- 🔴 **Приговор о чужой потере — только вторым замером.**
|
||||
- 🆕 🔴 **Увидел красноту у соседа — сперва проверь, не ТЫ ли её навёл.** Смена 6:
|
||||
помощник увидел четыре красных в чужом файле; это был **соседний круг той же
|
||||
смены**, шедший параллельно.
|
||||
- **Коммитить только поимённо**, сообщение в файле и **без круглых скобок**,
|
||||
состав перечитать через `git show --stat`.
|
||||
|
||||
### Про прод и базу
|
||||
|
||||
- 🔴 **`crm_app_user` не может создавать таблицы.**
|
||||
- 🔴 **Новым таблицам с защитой по строкам мало политики и прав** — ПЕРЕзапускать
|
||||
`db/03_service_bypass_policies.sql`.
|
||||
- 🔴 **Канон схемы разбит надвое:** `db/schema.sql` исполняется первой миграцией,
|
||||
таблицы дельта-миграций — в `db/schema_modules.sql`.
|
||||
- 🪤 **`db/**` сверять со снятием `\r`.**
|
||||
|
||||
---
|
||||
|
||||
## 7. ВОПРОСЫ, ВИСЯЩИЕ НА ВЛАДЕЛЬЦЕ
|
||||
|
||||
✅ **Шесть закрыты 07.08.2026** — Р122–Р127.
|
||||
|
||||
**Осталось, и первые четыре — новые:**
|
||||
|
||||
1. 🆕 🔴🔴 **ПАРА «10 ЦИФР / 11 ЦИФР» ДАЁТ ДВА ЗНАКА НА ОДНОГО ЧЕЛОВЕКА, И ЕМУ
|
||||
ПОЗВОНЯТ ДВАЖДЫ.** Замерено прибором надзирателя: `9990000001` и
|
||||
`79990000001` — знаки разные. При этом общий нормализатор портала и проверка
|
||||
запрета звонить считают эту пару **одним человеком**. То есть беда, ради
|
||||
которой принято Р127, на этой паре **жива**. 🔴 Лечение шире Р127: в обзвоне
|
||||
живут **ТРИ разных канона телефона**, и один из них сторожит **запрет звонить
|
||||
человеку**. Свести их — отдельная работа и отдельное решение владельца.
|
||||
2. 🆕 🔴🔴 **Часам менеджера НЕГДЕ ХРАНИТЬСЯ.** Ни таблицы, ни колонки; миграции
|
||||
были запрещены. ⇒ **Р64, Р115 и Р125 сегодня работают вхолостую**: клиент не
|
||||
может задать часы вовсе. И рамка обзвона тоже не управляема — ключей в
|
||||
настройках нет. **Нужна миграция, и это решение владельца.**
|
||||
3. 🆕 🔴 **Правило счёта попыток дозвона** (Р124). Знак задания различает попытки
|
||||
по числу попытки. Если портал прибавит попытку на повторе после обрыва —
|
||||
защита от двойного звонка **не сработает именно тогда, когда нужна**.
|
||||
4. 🆕 **З-0.4 заблокирована условием, которое не может исполниться.** План
|
||||
требует, чтобы З-0.1 была «закрыта и слита», а владелец разрешил по ней
|
||||
**только пробы в отдельное окно**. Условие надо либо снять, либо назначить
|
||||
окно.
|
||||
5. **Р92 — метки маскирования ПДн для таблиц обзвона.** Отложено; вернуть **до
|
||||
первого живого звонка**.
|
||||
6. 🔴 **По каким словам робот считает, что его попросили не звонить** (Р114).
|
||||
7. 🔴 **Попадает ли в список отказов исход «агрессия»** (Р114).
|
||||
8. 🔴 **Свободное место на машине портала не замерено** (Р105) — нужен доступ к
|
||||
боевому.
|
||||
9. **Входит ли хранение звука в цену звонка.**
|
||||
10. **Нужен ли замок в самой базе** против записи исхода мимо двери.
|
||||
11. **Читает ли кто-нибудь `ops@liderra.ru`** (Р117) — писем туда не приходило
|
||||
ни одного.
|
||||
12. **Как часто слать письмо при затяжной беде** (Р117).
|
||||
13. **Итог обзвона привязан к списку, а не к человеку** (Р121).
|
||||
14. 🆕 **Порядок смены тайного слова** (Р116/Р122) — не назначен.
|
||||
15. 🆕 **Переживают ли опознавательные знаки перезапуск робота** (Р124).
|
||||
16. 🆕 🔴 **Предел одновременных не бережёт машину так, как обещает Р84**: он
|
||||
считает **обращения**, а грузят её **разговоры**. Портал не закроет это сам.
|
||||
|
||||
🔴 **И одно, что решением НЕ закрыто, хотя выглядит закрытым.** Р114 велит
|
||||
роботу самому заносить отказника в список — но **связь «исход отказ → запись в
|
||||
списке» никто не строит**. Человек, сказавший «не звоните», попадёт в список,
|
||||
только если это построят. Это главная защита, и её пока нет.
|
||||
|
||||
---
|
||||
|
||||
## 8. ГРАНИЦЫ
|
||||
|
||||
- 🔴 **Боевой прод liderra.ru — живые клиенты и деньги.** Не трогать; база по
|
||||
умолчанию только чтение.
|
||||
- 🔴 **Сервер робота разрешён (Р84), но делит машину с рендером боевого
|
||||
«Поиска клиентов».** 🔴🔴 **Машину НЕ ПЕРЕЗАГРУЖАТЬ.** Проверки, требующие
|
||||
перезагрузки, заменяй и **говори вслух, чего замена не доказывает**.
|
||||
- 🔴 **Ничего не удалять на сервере робота** — Р94.
|
||||
- **Не пушить и не выкатывать** без слова владельца.
|
||||
- **В ветке работают четыре смены.** Чужие ветки не трогать; чужой конфликт не
|
||||
разрешать.
|
||||
- **Телефоны и слова людей в бумагах — маской.** Чужие записи и ПДн — только в
|
||||
`моя/` и `golos-test/`, обе в игноре.
|
||||
- **Открытые вопросы владельца не закрывать** без явного «закрываем».
|
||||
|
||||
**СТИЛЬ:** простой русский, без программистских слов — владелец не программист.
|
||||
**Не называть сделанным непроверенное. Свою ошибку называть первой.**
|
||||
**И работать строго по методу — это прямое требование владельца.**
|
||||
@@ -0,0 +1,102 @@
|
||||
# gen-api: настоящие названия моделей — взяты с сайта, не угаданы
|
||||
|
||||
**Снято 06.08.2026 вечером.** Прежняя смена угадывала названия, получала отказ 404 и
|
||||
объявила, что моделей нет. Ниже — список, снятый с самого сайта.
|
||||
|
||||
## Как брать список (запомнить, это не документировано публично)
|
||||
|
||||
Фронт `gen-api.ru` зовёт **свой** адрес, без ключа и без авторизации:
|
||||
|
||||
```text
|
||||
GET https://api.gen-api.ru/api/network/category/<раздел>/networks
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Разделы: `images`, `videos`, `3d`, `text`, `audio`.
|
||||
Обратите внимание: путь `/api/network/…`, **без** `v1`. Запуск генерации — другой путь,
|
||||
`POST https://api.gen-api.ru/api/v1/networks/<слуг>` с ключом.
|
||||
|
||||
Списка через `/api/v1/…` нет: `/api/v1/networks`, `/models`, `/catalog` дают 404.
|
||||
Страница `gen-api.ru/models` рисуется скриптом — `WebFetch` списки не видит.
|
||||
|
||||
Поля ответа: `slug`, `title`, `shot_description`, `price_info` (часто пустое),
|
||||
`price_tier`, `avg_generation_seconds`, `can_file`, `is_sota`, `tags`.
|
||||
|
||||
## Раздел 3D — 8 моделей
|
||||
|
||||
| Слуг | Название | Ср. время | Что делает |
|
||||
|---|---|---|---|
|
||||
| `trellis` | Trellis | 59 с | 3D по одной картинке |
|
||||
| `trellis-2` | Trellis 2 | 194 с | 3D по картинке, новее |
|
||||
| `rodin` | Hyper3D Rodin | 126 с | 3D по описанию и по картинке |
|
||||
| `hunyuan-3d` | Hunyuan3D v2 | 120 с | 3D по картинке |
|
||||
| `hunyuan-3d-multi-view` | Hunyuan3D Multi View v2 | 161 с | 3D по нескольким видам |
|
||||
| `hunyuan-motion` | Hunyuan-Motion | 44 с | движение для модели |
|
||||
| `meshy-v6` | Meshy V6 | 188 с | 3D, чистая сетка |
|
||||
| `tripo-3d` | Tripo3D H3.1 | 178 с | 3D по описанию и по картинке |
|
||||
|
||||
## Раздел «видео» — 66 моделей, отобранные
|
||||
|
||||
| Слуг | Название | Ср. время |
|
||||
|---|---|---|
|
||||
| `ltx-2` | LTX 2 | 61 с |
|
||||
| `ltx-2-3` | LTX 2.3 | 89 с |
|
||||
| `pixverse-v6` | PixVerse v6 | 66 с |
|
||||
| `pixverse-v6-transition` | PixVerse v6 Transition | 53 с |
|
||||
| `seedance-lite` | Seedance Lite | 72 с |
|
||||
| `seedance-1-5-pro` | Seedance 1.5 Pro | 117 с |
|
||||
| `kling-video-2-6` | Kling v2.6 Pro | 106 с |
|
||||
| `kling-video-v3` | Kling Video V3 | 132 с |
|
||||
| `veo-3-1-fast` | Veo 3.1 Fast | 80 с |
|
||||
| `veo-3-1-lite` | Veo 3.1 Lite | 97 с |
|
||||
| `sora-2` | Sora 2 | 171 с |
|
||||
| `vidu-q3-video` | ViduQ3Video | 95 с |
|
||||
| `wan-2-6-video` | Wan 2.6 Image-to-Video | 86 с |
|
||||
| `grok-video` | Grok Video | 71 с |
|
||||
| `luma` | Luma Ray2 | 323 с | 30 ₽ за генерацию (единственная модель с прямо указанной ценой) |
|
||||
|
||||
## Раздел «картинки» — 93 модели, отобранные
|
||||
|
||||
| Слуг | Название | Ср. время |
|
||||
|---|---|---|
|
||||
| `seedream-v5-pro` | Seedream V5 Pro | — |
|
||||
| `seedream-v5-lite` | Seedream v5 Lite | — |
|
||||
| `seedream-v4-5` | Seedream 4.5 | — |
|
||||
| `nano-banana-pro` | Nano Banana Pro | — |
|
||||
| `nano-banana-2` | Nano Banana 2 | — |
|
||||
| `nano-banana-lite` | Nano Banana Lite | — |
|
||||
| `flux-2-pro` / `flux-2-max` / `flux-2-flex` / `flux-2-klein` | Flux 2 | — |
|
||||
| `flux` | Flux (старый, жёсткий список разрешений) | 14 с |
|
||||
| `gpt-image-2` / `gpt-image-1-5` | GPT Image | — |
|
||||
| `imagen-4` | Imagen 4 | 24 с |
|
||||
| `recraft-v4` | Recraft v4 Pro (вектор и логотипы) | — |
|
||||
| `qwen-image-max` | Qwen Image Max | — |
|
||||
| `hunyuan-image-v3` | Hunyuan Image V3 | — |
|
||||
| `image-reframe` | Reframe — **расширяет картинку под другое соотношение** | — |
|
||||
| `midas` | MiDaS — **карта глубины по картинке** | 7 с |
|
||||
| `image-2-svg` | Image2SVG — векторизация | 11 с |
|
||||
| `clarity-upscaler` / `seedvr` / `recraft-upscaler` | увеличение | — |
|
||||
| `pixelcut-background-removal` / `ideogram-remove-background` | вырезание фона | — |
|
||||
| `iclight-v2` | IC-Light V2 — **переосвещение картинки** | — |
|
||||
|
||||
## Цены
|
||||
|
||||
`price_info` у большинства моделей **пустое**. Прямо названы:
|
||||
|
||||
- `luma` — 30 ₽ за генерацию видео;
|
||||
- `midjourney` — 10 ₽ за четыре картинки;
|
||||
- `sdxl-lightning` — ~0,2 ₽ за картинку;
|
||||
- `sdxl` / `sd3` — ~1 ₽ за картинку 1024×1024, 25 шагов;
|
||||
- `real-esrgan` — ~5 ₽ за увеличение вдвое;
|
||||
- `fooocus` — ~6 ₽ за картинку;
|
||||
- `midas` — копейки.
|
||||
|
||||
Цены остальных узнавать замером: снять баланс `GET /api/v1/user`, запустить, снять снова.
|
||||
|
||||
## Замер по старому `flux`
|
||||
|
||||
Жёсткий список разрешений, соотношения 2:1 среди них нет:
|
||||
**3136×1344, 2048×2048, 2752×1536, 2368×1792, 1344×3136, 1536×2752, 1792×2368**.
|
||||
За отклонённый запрос деньги не списываются.
|
||||
Для панорамы 2:1 старый `flux` не годится — брать `flux-2-pro`, `seedream-v5-pro`
|
||||
или `nano-banana-pro`, либо расширять квадрат через `image-reframe`.
|
||||
@@ -547,6 +547,49 @@ Asterisk и читает свой файл. Общий файл остаётся
|
||||
|
||||
### З-0.3. Портальная сторона шва: портал умеет позвать робота
|
||||
|
||||
> ✅ **ПОСТРОЕНО 07.08.2026** — коммиты `18f933550` и доделка `dd3b6fb4b` (Р123).
|
||||
> Приговор:
|
||||
> [priyomka/stroyka-6/z-0-3-portalnaya-storona-shva-2026-08-06.md](../priyomka/stroyka-6/z-0-3-portalnaya-storona-shva-2026-08-06.md).
|
||||
>
|
||||
> Построен клиент с тремя умениями — позвонить, спросить состояние, остановить —
|
||||
> плюс мягкая деградация, предел одновременных общими замками, два предела
|
||||
> времени и **проверка шифрования** (Р123). Сторожей 82.
|
||||
>
|
||||
> 🔴 **Чем замер разошёлся с текстом задачи — пять мест:**
|
||||
>
|
||||
> 1. 🔴 **Задание надзирателя содержало ПРОТИВОРЕЧИЕ САМОМУ СЕБЕ:** велело
|
||||
> считать ответ «200 и мусор» **отказом**, и тем же заданием объясняло, что
|
||||
> молчание ещё не значит отсутствие звонка. Буквальное исполнение вернуло бы
|
||||
> ровно ту дыру, которую задание закрывало, — **живому человеку позвонили бы
|
||||
> дважды**. Помощник поступил правильно, а не послушно: мусор → «не знаю».
|
||||
> 2. 🔴 **Нож надзирателя нашёл: ключ уезжал ОТКРЫТЫМ ТЕКСТОМ**, если адрес
|
||||
> записан без шифрования. Закрыто решением владельца Р123 и доделкой
|
||||
> `dd3b6fb4b`; проверено моим ножом на **восьми формах адреса на боевом
|
||||
> окружении** — большие буквы, порт и пробелы проходят, `http`, `httpsX`,
|
||||
> `https` внутри пути и адрес без схемы закрыты.
|
||||
> 3. 🔴 **Подсказка надзирателя к Р123 привела бы к НЕГОДНОЙ починке.** Я
|
||||
> предложил определять «свою машину» по тому, где живёт приложение. Помощник
|
||||
> показал: этого мало — сторожа гоняются в не-боевом окружении, и адрес
|
||||
> `http://51.250.1.97` прошёл бы наружу. Нужны **два независимых ответа**, и
|
||||
> оба должны совпасть.
|
||||
> 4. 🔴 **Предел одновременных считает ОБРАЩЕНИЯ, а не разговоры.** Место
|
||||
> освобождается сразу после «принял», а разговор живёт минуты и грузит машину
|
||||
> именно он. ⇒ **Р84 этим пределом не закрыт**, и портал не закроет его в
|
||||
> одиночку, пока робот не начнёт сообщать, сколько разговоров идёт.
|
||||
> 5. 🔴 **Знак задания не различает перезапуск кампании** — через месяц у тех же
|
||||
> номеров он тот же. Закрыто решением владельца **Р124**: робот помнит знаки
|
||||
> сутки.
|
||||
>
|
||||
> 🔑 **Помощник нашёл сверх задания:** `Http::assertSentCount` — **негодный
|
||||
> датчик на оборванных запросах**: оборванный запрос в список отправленных не
|
||||
> кладётся, счётчик показывает ноль при любом числе повторов. Его проверка
|
||||
> «клиент не повторяет сам» на этом датчике была бы **вечнозелёной**.
|
||||
>
|
||||
> 🔴 **Незакрытое:** защита от двойного звонка **заявлена, но не замкнута** —
|
||||
> узнаёт повтор робот, а роботной половины ещё нет; клиент нигде не
|
||||
> зарегистрирован, собирается из настроек; настоящие пределы времени доказаны
|
||||
> подставным роботом.
|
||||
|
||||
**Что есть.** Портал уже ходит на эту самую виртуалку по HTTP — за рендером:
|
||||
`app/config/services.php`, блок `self_render` (адрес и общий ключ в `.env`, предел
|
||||
одновременных запросов, адрес проверки живости). Это **готовый образец шва**, и
|
||||
@@ -672,6 +715,38 @@ Asterisk и читает свой файл. Общий файл остаётся
|
||||
|
||||
### З-0.6. Робот берёт задание и отчитывается — два плеча и каждая попытка
|
||||
|
||||
> 🟡 **ПОСТРОЕНА ПЕРВАЯ ПОЛОВИНА 07.08.2026** — коммиты `f409f874` и доделка
|
||||
> `f671e21d`. **ПОСТРОЕННОЙ задача НЕ считается.** Приговор:
|
||||
> [priyomka/stroyka-6/z-0-6-priyomnik-zadaniy-2026-08-07.md](../priyomka/stroyka-6/z-0-6-priyomnik-zadaniy-2026-08-07.md).
|
||||
>
|
||||
> Построен **приёмник заданий** — три ручки, память опознавательных знаков на
|
||||
> сутки (Р124) базой на диске, проверка ключа. Проба без звонка — **108
|
||||
> проверок**, прогнана надзирателем своей рукой.
|
||||
>
|
||||
> 🔴 **Из восьми проверок задачи закрыта ОДНА** (чужой или пустой ключ — отказ),
|
||||
> две частично, пять не закрыты вовсе. Вторая половина — отчёт робота о
|
||||
> результате разговора — живёт в `most.py` и ждёт З-0.5.
|
||||
>
|
||||
> 🔴 **Чем замер разошёлся с текстом задачи — три места:**
|
||||
>
|
||||
> 1. 🔴🔴 **В плане не хватало ЦЕЛОГО КУСКА — самого набора номера.** Нашёл
|
||||
> помощник, надзиратель проверил вторым прибором. Заведена **З-0.12**.
|
||||
> 2. 🔴 **Сторож помощника читал ПОЯСНЕНИЕ, а не код:** проверку «ключи
|
||||
> сравниваются постоянным по времени способом» он сделал поиском слова по
|
||||
> тексту файла. Сломал сравнение нарочно — **проба осталась зелёной, 81 из
|
||||
> 81**: слово нашлось в его же пояснении строкой выше. Починил, заглянув в
|
||||
> скомпилированный код; надзиратель перепроверил своим прибором.
|
||||
> 3. 🔴 **Нож надзирателя нашёл дыру:** тот же знак с **другим телефоном**
|
||||
> приёмник считал повтором, молча не звонил второму человеку и возвращал
|
||||
> порталу **номер звонка первого**. Закрыто доделкой: знак помнится вместе с
|
||||
> отпечатком тела, расхождение — громкий отказ 422.
|
||||
>
|
||||
> 🔴 **Незакрытое, названное помощником:** причина дыры **на портале** — знак
|
||||
> считается от одних цифр телефона, а в тело кладётся номер как есть, поэтому
|
||||
> один и тот же человек в разной записи даёт **один знак и разные тела**;
|
||||
> приёмник не выкачен и слушает без шифрования; своего сторожа сверки с машиной
|
||||
> у него нет.
|
||||
|
||||
**Что делать.** Вторая половина шва: робот принимает от портала «позвони
|
||||
туда-то, вот скрипт, вот имя, вот номер менеджера для перевода», а после
|
||||
разговора отдаёт обратно:
|
||||
@@ -717,6 +792,61 @@ Asterisk и читает свой файл. Общий файл остаётся
|
||||
|
||||
---
|
||||
|
||||
### З-0.12. 🔴 Робот вправду НАБИРАЕТ номер
|
||||
|
||||
> 🆕 **Задача заведена 07.08.2026 сменой 6 по решению владельца.** Стоит здесь
|
||||
> **по порядку работ, а не по номеру**: её место — сразу за швом.
|
||||
>
|
||||
> **Как нашлась.** Помощник, строивший приёмник заданий (З-0.6), разобрал моё
|
||||
> задание и сказал: «разрез верный, но линия не там — между „принять задание" и
|
||||
> „отчитаться" лежит **сам набор номера**, и его нет ни в одной половине».
|
||||
> Я проверил **вторым прибором**, перебрав все 53 задачи плана поимённо:
|
||||
> задачи про набор нет **ни в одной волне**. Задача про дозвон (З-3.3) — это
|
||||
> правила повторов, а не сам набор.
|
||||
>
|
||||
> 🔴 **Замерено, что есть и чего нет:**
|
||||
>
|
||||
> - в диалплане робота (`bots/lena-golos/extensions-lena.conf`) набор наружу
|
||||
> **есть** — `Dial(PJSIP/${DEST}@mango-trunk,60)`, им пользуется перевод;
|
||||
> - **начать исходящий звонок некому.** Ни в диалплане, ни в мосте, ни в
|
||||
> приёмнике нет ничего, что сказало бы станции «набери такой-то номер и веди
|
||||
> его по такому-то сценарию». Поиск по всем файлам робота — ноль совпадений.
|
||||
>
|
||||
> ⇒ **Сегодняшняя цепочка обрывается так:** портал говорит «позвони» → робот
|
||||
> принимает задание и отвечает «принято» → **дальше не происходит ничего.**
|
||||
> Помощник З-0.6 нарочно оставил на месте набора заглушку, честно отвечающую
|
||||
> «набор не настроен», и это правильно: приёмник **выглядит бесполезным вместо
|
||||
> того, чтобы выглядеть работающим**.
|
||||
|
||||
**Что делать.** Робот, получив задание, **вправду набирает номер** и ведёт
|
||||
разговор по сценарию: станция получает команду начать исходящий звонок, звонок
|
||||
привязывается к заданию, а его номер возвращается порталу.
|
||||
|
||||
🔴 **Прежде чем строить — замерить, через кого звоним.** Решение владельца
|
||||
**Р81**: телефония переезжает на новую АТС, Манго — только испытание. Всё
|
||||
построенное сегодня опирается на манговский ствол. Задача обязана начаться с
|
||||
замера: жив ли ствол, и не переехали ли уже.
|
||||
|
||||
**Файлы:** `bots/lena-golos/priyomnik.py` (сменная деталь набора — она уже
|
||||
заведена и ждёт), `bots/lena-golos/extensions-lena.conf` (⚠️ узкое горло, его
|
||||
трогали З-0.1 и З-0.2), настройка связи со станцией.
|
||||
|
||||
**Проверки:**
|
||||
|
||||
1. Портал послал задание — станция **вправду начала звонок** на тот номер.
|
||||
2. Звонок связан с заданием: по номеру звонка портал находит своё задание.
|
||||
3. Станция не отвечает или ствол мёртв — приёмник отвечает так, что портал
|
||||
понимает: **звонка не было**, а не «может быть, был».
|
||||
4. Набор не начался — заглушка не притворяется успехом.
|
||||
5. 🔴 Нагрузки на машину не прибавилось сверх положенного (Р84): она делит
|
||||
железо с рендером боевого «Поиска клиентов».
|
||||
6. Два задания подряд не сливаются в один звонок.
|
||||
|
||||
🔴 **Чего эта задача НЕ закрывает:** сам разговор, перевод на менеджера (З-0.4),
|
||||
отчёт о результате (вторая половина З-0.6) и правила повторов (З-3.3).
|
||||
|
||||
---
|
||||
|
||||
### З-0.7. Наставления робота — в репозиторий
|
||||
|
||||
🔴 **Задача найдена судьёй. Моя пропущенная ошибка.**
|
||||
@@ -959,6 +1089,38 @@ Asterisk и читает свой файл. Общий файл остаётся
|
||||
|
||||
### З-0.10. Сторожей машины робота кто-то зовёт, и молчание слышно
|
||||
|
||||
> ✅ **ПОСТРОЕНО 06.08.2026** — коммиты `67195f0b` и доделка `c351b0b6`.
|
||||
> Приговор вынесен сменой 6 **через сутки после работы**, то есть отчёт
|
||||
> помощника доказательством не служил вовсе:
|
||||
> [priyomka/stroyka-5/z-0-10-storozha-mashiny-2026-08-06.md](../priyomka/stroyka-5/z-0-10-storozha-mashiny-2026-08-06.md).
|
||||
>
|
||||
> 🔴 **Чем замер разошёлся с текстом задачи — четыре места:**
|
||||
>
|
||||
> 1. **«На машине живут три сторожа» — неверно.** Три из четырёх живут в
|
||||
> хранилище и ходят на машину по ssh; на машине лежал **один**. Просто
|
||||
> «завести расписание» было нельзя: своего ключа у машины нет, а заводить
|
||||
> ключ от самой себя на машине с голосами живых людей помощник **отказался**.
|
||||
> У сторожей появилась вторая створка «мы уже на этой машине».
|
||||
> 2. 🔴 **Решение владельца Р116 исполнено ИНАЧЕ, чем записано.** Владелец
|
||||
> назвал «тайное слово в адресе». Помощник взял **подпись тела**: адреса
|
||||
> целиком текут в журналы веб-сервера, слово оттуда утекает всякому, кто их
|
||||
> читает. ✅ **Владелец подтвердил замену 07.08.2026** — оставляем подпись.
|
||||
> 3. ✅ **Проверка 1 «итог появляется сам» ЗАКРЫТА ЖИВЬЁМ.** Доказать её
|
||||
> раньше 02:20 UTC было нечем; надзиратель **дождался ночи и замерил**:
|
||||
> 07.08.2026 в 02:20:01 UTC обход отработал **сам, без человека**, за 2
|
||||
> секунды, все три сторожа зелёные, итог лёг со своей датой.
|
||||
> 🟢 Уборка записей чужих голосов перестала зависеть от человеческой памяти —
|
||||
> это **замерено**, а не обещано.
|
||||
> 4. 🔴 **Дыру нашёл нож надзирателя, а не проверки задачи:** дверь принимала
|
||||
> итог зелёным, когда тело само говорило «красных 2», а в списке лежал один
|
||||
> зелёный. Закрыто доделкой `c351b0b6`.
|
||||
>
|
||||
> **Осталось незакрытым:** портальная половина **не выкачена** — сторож обхода
|
||||
> честно краснеет одной проверкой из 42, и этот красный есть датчик
|
||||
> завершённости выката; «письмо уйдёт» ничем не закрыто (умри почта портала —
|
||||
> замолчит сам присмотр); письмо второго слоя уходит на личную почту в чужом
|
||||
> боевом коде; `proverka-zagotovok.sh` в обход намеренно не входит.
|
||||
|
||||
**Откуда взялась.** Решение владельца **Р111** от 06.08.2026.
|
||||
|
||||
**Что делать.** На машине робота живут три сторожа — 26 проверок на уборку звука,
|
||||
@@ -1987,6 +2149,67 @@ Asterisk и читает свой файл. Общий файл остаётся
|
||||
|
||||
### З-2.4. Статус обзвона в сделке и пометки касаний
|
||||
|
||||
> ✅ **ПОСТРОЕНО 06.08.2026, решения владельца Р112, Р113, Р120, Р121** —
|
||||
> коммиты `bde377a11` и `df8c4720c`. Приговор:
|
||||
> [priyomka/stroyka-5/z-2-4-status-obzvona-v-sdelke-2026-08-06.md](../priyomka/stroyka-5/z-2-4-status-obzvona-v-sdelke-2026-08-06.md).
|
||||
> **Этой задачей закрыта волна 2.**
|
||||
>
|
||||
> **Что построено:** `app/config/obzvon.php` — раскладка исходов **в одном
|
||||
> месте настройкой**; `RaskladkaIskhodov` — правило спора об исходе;
|
||||
> `ItogPoNomeru` — единственная дверь записи; `SvodkaObzvonaVSdelke` — чтение;
|
||||
> `DealDetailObzvon.vue` + `obzvonIskhody.ts`; пометки касаний в
|
||||
> `DealsTable.vue`. Точечно правлено 6 существующих файлов, **ноль удалённых
|
||||
> строк**. Чистый полный прогон надзирателя: **5140 проверок, 5136 зелёных,
|
||||
> красных 0**.
|
||||
>
|
||||
> **Чем замер разошёлся с текстом задачи — четыре расхождения:**
|
||||
>
|
||||
> 1. 🔴🔴 **Задача называла НЕ ТЕ файлы.** Поле «Обзвон» — это **карточка**
|
||||
> сделки, а она живёт в `DealDetailBody.vue`, `DealDetailDrawer.vue`,
|
||||
> `DealDetailHero.vue`, `DealDetailTimeline.vue`. Ни один планом не назван.
|
||||
> `DealsView.vue` и `DealsTable.vue` — это **список**, туда идут только
|
||||
> пометки касаний.
|
||||
> 2. 🔴 **Пометки касаний сужены до ДВУХ модулей, и это верно.** У телеграма и
|
||||
> рекламы **нет связи со сделкой вовсе** — задача в исходном виде
|
||||
> неисполнима. Третий модуль стоит одного запроса и одного слова.
|
||||
> 3. 🔴 **Ловушка `no_answer` была настоящей и ничем не закрытой.** Замок в базе
|
||||
> сторожит **список значений, а не смысл**. Закрыто **полом в самой модели**:
|
||||
> он ловит любую запись через объект и имени переменной не читает.
|
||||
> 4. 🔑 **Вопрос владельцу про «0,4 секунды» стоял неверно** — длительность
|
||||
> хранится целыми секундами. Развилка была «одна, три или пять», владелец
|
||||
> выбрал **одну** (Р120).
|
||||
>
|
||||
> 🔴 **ДЫРА, НАЙДЕННАЯ НОЖОМ НАДЗИРАТЕЛЯ — ПРОВЕРИТЬ СЛЕДУЮЩЕЙ СМЕНЕ.**
|
||||
> Допущение защиты: «у всякой записи мимо двери есть в выражении имя класса или
|
||||
> имя таблицы». Случай мимо — **правка через связь**:
|
||||
> `$sdelka->obzvonItogi()->update(['outcome' => …])`. Ни того, ни другого имени
|
||||
> нет; пол не сработает — это построитель, а не объект. **Замерено подставным
|
||||
> пробником: 8 из 8 зелёных, ни одной улики.**
|
||||
> 🔑 Связи `сделка → итоги` сегодня нет — но заведёт её ровно тот, кто будет
|
||||
> строить дальше, и тогда **самый естественный способ записи окажется
|
||||
> единственным несторожимым**. Починка дешева: столбец `outcome` в чистом виде
|
||||
> есть **ровно у одной таблицы** (`db/schema_modules.sql:1351`), у соседней он
|
||||
> зовётся `attempt_outcome` — зацепка по столбцу ложной красноты не даст.
|
||||
> ✅ **ДЫРА ЗАКРЫТА И ПРИНЯТА** — коммит `6e775055`. Закрыта **не текстом**:
|
||||
> заведён свой построитель, подставляемый моделью, — он ловит **на самой
|
||||
> записи** разом все три формы: через модель, **через связь**, через новый
|
||||
> запрос. Перепроверено тем же ножом надзирателя: файл, прежде дававший 8 из 8
|
||||
> зелёных, теперь роняет сторожа и называет **строку 21** и само выражение.
|
||||
> 🔑 **Зацепка, которую предлагал надзиратель, была негодна, и помощник
|
||||
> поправил её дважды:** подстрока `outcome` совпадает с `attempt_outcome` —
|
||||
> краснело бы ни за что; и ключ уникален **в схеме, но не в коде** — он есть в
|
||||
> пояснении к самой двери, так что обход по тексту **покраснел бы на двери**.
|
||||
> 🪤 **Чего не ловит и это:** прямой SQL мимо портала · ключ из кусков · имя
|
||||
> таблицы из кусков · **столбец, переименованный в будущем** · код вне
|
||||
> `app/app`. 🔴 **Полной защиты нет без замка в самой базе** — открытый долг.
|
||||
>
|
||||
> 🔴 **Осталось незакрытым:** `obzvon_calls.attempt_outcome` — **второе место
|
||||
> того же класса**, четыре значения в одном столбце, лечить вместе с тем, кто
|
||||
> их пишет; живой работы модуля нет, всё на данных, заведённых руками; надпись
|
||||
> «Backend недоступен» обещает выдуманные события, а код кладёт пустоту; итог
|
||||
> привязан к **списку, а не к человеку** — при смене владельца номера карточка
|
||||
> сольёт историю двух разных людей.
|
||||
|
||||
**Что делать.** В сделке появляется поле «Обзвон» с записью разговора и **своим
|
||||
статусом обзвона** (Т65): переведено · переведено неуспешно · перезвонить, дата и
|
||||
время · письмо клиенту отправлено · не целевой · отказ · недозвон · агрессия.
|
||||
@@ -2228,6 +2451,37 @@ Asterisk и читает свой файл. Общий файл остаётся
|
||||
|
||||
### З-3.1. Окно часов: общая рамка и своё окно клиента
|
||||
|
||||
> ✅ **ПОСТРОЕНО 07.08.2026** — коммит `5dfe32dc9`. Приговор:
|
||||
> [priyomka/stroyka-6/z-3-1-okno-chasov-2026-08-07.md](../priyomka/stroyka-6/z-3-1-okno-chasov-2026-08-07.md).
|
||||
>
|
||||
> 🟢 **Ловушка Р115 закрыта и проверена ножом надзирателя на каждом часе суток:**
|
||||
> менеджер в Москве 14:00–18:00 и получатель во Владивостоке — набор **не
|
||||
> разрешён ни в один час**; менеджер 10:00–18:00 — разрешён ровно в три часа,
|
||||
> и арифметика сходится знак в знак.
|
||||
>
|
||||
> 🔴 **Чем замер разошёлся с текстом задачи — четыре места:**
|
||||
>
|
||||
> 1. 🔴🔴 **Разведка надзирателя была ЛОЖНОЙ в трёх местах, и помощник её
|
||||
> разбил.** «Правило зовут двенадцать файлов» — зовущих **пять**, двенадцать
|
||||
> это упоминания, шесть из них в пояснениях. «Обзвон уже читает правило через
|
||||
> тарификатор» — **не читает вовсе**, там одна строка в пояснении. «У
|
||||
> `canSendNow` двенадцать мест живого кода» — **один** боевой зовущий.
|
||||
> ⇒ Вывод «подпись не менять» верен, но по **другой** причине: у модуля
|
||||
> рассылки менеджера нет вовсе, и все пять мест до конца дней передавали бы
|
||||
> «менеджера нет».
|
||||
> 2. **«Правку живого модуля» доказывали не тестами, а вычитанием:** старое
|
||||
> правило вынуто из хранилища временным эталоном и сверено с новым на
|
||||
> **13 поясах × 1440 минутах × 7 наборах границ** — 144 816 сверок, **0
|
||||
> расхождений**; прибор показан красным (подложенная поломка дала 6049).
|
||||
> 3. 🔴 **Часам менеджера негде храниться** — ни таблицы, ни колонки, миграции
|
||||
> были запрещены. Окно приходит доводом снаружи, **клиент задать их не может**.
|
||||
> 4. 🔴 **Рамка обзвона пока не управляема:** ключей в настройках нет, до
|
||||
> миграции админ окно обзвона не поменяет. Разделение источников при этом
|
||||
> настоящее и засторожено.
|
||||
>
|
||||
> ✅ **Развилка «а если общего окна нет вовсе» закрыта владельцем — Р125:**
|
||||
> не звонить и **сказать клиенту до запуска**.
|
||||
|
||||
🔴 **Развилка 5 закрыта Р64. Задача разблокирована целиком** — в первой редакции
|
||||
она была наполовину замороженной.
|
||||
|
||||
|
||||