diff --git a/app/.env.example b/app/.env.example index c8c30cee..48cd689c 100644 --- a/app/.env.example +++ b/app/.env.example @@ -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 diff --git a/app/app/Console/Commands/LenaProverkaObhodaStorozhey.php b/app/app/Console/Commands/LenaProverkaObhodaStorozhey.php new file mode 100644 index 00000000..f8649f82 --- /dev/null +++ b/app/app/Console/Commands/LenaProverkaObhodaStorozhey.php @@ -0,0 +1,110 @@ +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; + } +} diff --git a/app/app/Http/Controllers/Api/AdminTenantsController.php b/app/app/Http/Controllers/Api/AdminTenantsController.php index 20e2d3ef..2fb96017 100644 --- a/app/app/Http/Controllers/Api/AdminTenantsController.php +++ b/app/app/Http/Controllers/Api/AdminTenantsController.php @@ -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> */ - 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>, 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'; } /** diff --git a/app/app/Http/Controllers/Api/LenaStorozhaWebhookController.php b/app/app/Http/Controllers/Api/LenaStorozhaWebhookController.php new file mode 100644 index 00000000..74737560 --- /dev/null +++ b/app/app/Http/Controllers/Api/LenaStorozhaWebhookController.php @@ -0,0 +1,362 @@ +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 $itog + * @return list + */ + 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 $itog + * @param list $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|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; + } + } +} diff --git a/app/app/Mail/LenaStorozhaTrevogaMail.php b/app/app/Mail/LenaStorozhaTrevogaMail.php new file mode 100644 index 00000000..a8670f52 --- /dev/null +++ b/app/app/Mail/LenaStorozhaTrevogaMail.php @@ -0,0 +1,51 @@ +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(), + ], + ); + } +} diff --git a/app/app/Models/ObzvonNumberResult.php b/app/app/Models/ObzvonNumberResult.php index 431122b8..28f4f7e4 100644 --- a/app/app/Models/ObzvonNumberResult.php +++ b/app/app/Models/ObzvonNumberResult.php @@ -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 + */ + public static function iskhodyProNomer(): array + { + $proPopytku = self::spisokIskhodov('pro_popytku'); + + return array_values(array_diff(self::OUTCOMES, $proPopytku)); + } + + /** @return list */ + private static function spisokIskhodov(string $rod): array + { + /** @var array $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); + } } diff --git a/app/app/Models/ObzvonNumberResultBuilder.php b/app/app/Models/ObzvonNumberResultBuilder.php new file mode 100644 index 00000000..c7320738 --- /dev/null +++ b/app/app/Models/ObzvonNumberResultBuilder.php @@ -0,0 +1,105 @@ +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 + */ +class ObzvonNumberResultBuilder extends Builder +{ + /** + * @param array $values + */ + public function update(array $values): int + { + $this->proveritPolT84($values); + + return parent::update($values); + } + + /** + * Запрещён ровно один переход: исход «про попытку» поверх строки, которая + * уже держит исход «про номер». Всё остальное пропускается. + * + * 🔴 Считаем ПО ФАКТУ, а не по намерению: спрашиваем базу, есть ли среди + * строк под правку хоть одна с состоявшимся разговором. Массовая правка + * тем и опасна, что пишущий не знает, что лежит в каждой строке, — значит + * знать обязан пол. + * + * @param array $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 — он решает ' + .'по каждому номеру отдельно, перебивает новый исход прежний или проходит ' + .'бесследно.' + ); + } +} diff --git a/app/app/Providers/AutopodborServiceProvider.php b/app/app/Providers/AutopodborServiceProvider.php index 94079265..447b83c2 100644 --- a/app/app/Providers/AutopodborServiceProvider.php +++ b/app/app/Providers/AutopodborServiceProvider.php @@ -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 + */ + private static function razreshyonnyeHosty(): array + { + /** @var array $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(), ); } diff --git a/app/app/Services/Admin/TenantActivityFeedService.php b/app/app/Services/Admin/TenantActivityFeedService.php new file mode 100644 index 00000000..ad845e9d --- /dev/null +++ b/app/app/Services/Admin/TenantActivityFeedService.php @@ -0,0 +1,125 @@ + + */ + 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 */ + private function unionBindings(int $tenantId): array + { + return [$tenantId, $tenantId, $tenantId, $tenantId, $tenantId]; + } +} diff --git a/app/app/Services/Admin/TenantAlivenessService.php b/app/app/Services/Admin/TenantAlivenessService.php new file mode 100644 index 00000000..3a2fdf64 --- /dev/null +++ b/app/app/Services/Admin/TenantAlivenessService.php @@ -0,0 +1,167 @@ +, + * 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 */ + 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), + ]; + } +} diff --git a/app/app/Services/Autopodbor/Agent/Fetch/DverRendera.php b/app/app/Services/Autopodbor/Agent/Fetch/DverRendera.php new file mode 100644 index 00000000..1be0b4de --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Fetch/DverRendera.php @@ -0,0 +1,165 @@ + */ + private readonly array $razreshyonnyeHosty; + + /** + * @param list $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"; + } +} diff --git a/app/app/Services/Autopodbor/Agent/Fetch/RenderServiceYandexListSource.php b/app/app/Services/Autopodbor/Agent/Fetch/RenderServiceYandexListSource.php index fedace1b..a5869168 100644 --- a/app/app/Services/Autopodbor/Agent/Fetch/RenderServiceYandexListSource.php +++ b/app/app/Services/Autopodbor/Agent/Fetch/RenderServiceYandexListSource.php @@ -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": "" }`. Без 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 $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]); diff --git a/app/app/Services/Autopodbor/Agent/Fetch/SelfRenderClient.php b/app/app/Services/Autopodbor/Agent/Fetch/SelfRenderClient.php index dc9642b5..fe675768 100644 --- a/app/app/Services/Autopodbor/Agent/Fetch/SelfRenderClient.php +++ b/app/app/Services/Autopodbor/Agent/Fetch/SelfRenderClient.php @@ -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": "" }`; не-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 $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; } } diff --git a/app/app/Services/ClientSms/LocalHoursWindow.php b/app/app/Services/ClientSms/LocalHoursWindow.php new file mode 100644 index 00000000..881d77fb --- /dev/null +++ b/app/app/Services/ClientSms/LocalHoursWindow.php @@ -0,0 +1,59 @@ +fromHour, $this->toHour); + } +} diff --git a/app/app/Services/ClientSms/SmsQuietHours.php b/app/app/Services/ClientSms/SmsQuietHours.php index 4f25b79e..5861dfb6 100644 --- a/app/app/Services/ClientSms/SmsQuietHours.php +++ b/app/app/Services/ClientSms/SmsQuietHours.php @@ -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; } diff --git a/app/app/Services/External/ProxyMarketProbe.php b/app/app/Services/External/ProxyMarketProbe.php index b926de2f..101ab7fa 100644 --- a/app/app/Services/External/ProxyMarketProbe.php +++ b/app/app/Services/External/ProxyMarketProbe.php @@ -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 + */ + private static function razreshyonnyeHosty(): array + { + /** @var array $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 : ''; diff --git a/app/app/Services/Obzvon/IskhodObrashcheniya.php b/app/app/Services/Obzvon/IskhodObrashcheniya.php new file mode 100644 index 00000000..65cbc2ba --- /dev/null +++ b/app/app/Services/Obzvon/IskhodObrashcheniya.php @@ -0,0 +1,82 @@ +, "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 $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 $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 $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 $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 $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 $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); + } + } +} diff --git a/app/app/Services/Obzvon/ObzvonOknoChasov.php b/app/app/Services/Obzvon/ObzvonOknoChasov.php new file mode 100644 index 00000000..f38c08b1 --- /dev/null +++ b/app/app/Services/Obzvon/ObzvonOknoChasov.php @@ -0,0 +1,182 @@ +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; + } +} diff --git a/app/app/Services/Obzvon/ObzvonOtvet.php b/app/app/Services/Obzvon/ObzvonOtvet.php new file mode 100644 index 00000000..d65666b1 --- /dev/null +++ b/app/app/Services/Obzvon/ObzvonOtvet.php @@ -0,0 +1,101 @@ +ok()`. Он звал бы читателя + * написать `if (! $otvet->ok()) { повторить(); }`, а это ровно та строчка, из-за + * которой живому человеку звонят дважды: «не ok» включает в себя и «не знаю». + * Вместо него два вопроса, на которые нельзя ответить не подумав: + * • {@see self::poluchen()} — робот сказал условленное слово; + * • {@see self::zvonokMogSostoyatsya()} — можно ли вообще считать, что звонка + * не было. + */ +final class ObzvonOtvet +{ + /** + * @param array $telo разобранное тело ответа робота (пусто, если ответа не было) + */ + private function __construct( + public readonly IskhodObrashcheniya $iskhod, + public readonly string $poyasnenie, + public readonly ?string $vneshniyIdZvonka = null, + public readonly array $telo = [], + ) {} + + /** + * Робот ответил условленным словом. + * + * @param array $telo + */ + public static function poluchen(string $poyasnenie, ?string $vneshniyIdZvonka = null, array $telo = []): self + { + return new self(IskhodObrashcheniya::Poluchen, $poyasnenie, $vneshniyIdZvonka, $telo); + } + + /** + * Робот сам сказал «нет». + * + * @param array $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; + } +} diff --git a/app/app/Services/SchedulerHeartbeatTracker.php b/app/app/Services/SchedulerHeartbeatTracker.php index 3773c2d7..3d8ee5a4 100644 --- a/app/app/Services/SchedulerHeartbeatTracker.php +++ b/app/app/Services/SchedulerHeartbeatTracker.php @@ -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 ]; /** diff --git a/app/config/obzvon.php b/app/config/obzvon.php index 5664505a..33521813 100644 --- a/app/config/obzvon.php +++ b/app/config/obzvon.php @@ -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, ], diff --git a/app/config/services.php b/app/config/services.php index 0bd0fbd4..858f035f 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -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 !== '', + )), + ], + ]; diff --git a/app/config/storozha_mashiny.php b/app/config/storozha_mashiny.php new file mode 100644 index 00000000..06dfbe97 --- /dev/null +++ b/app/config/storozha_mashiny.php @@ -0,0 +1,72 @@ + (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', +]; diff --git a/app/resources/js/api/admin.ts b/app/resources/js/api/admin.ts index 1fd09951..55c92d4a 100644 --- a/app/resources/js/api/admin.ts +++ b/app/resources/js/api/admin.ts @@ -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 | 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 { + const { data } = await apiClient.get( + `/api/admin/tenants/${encodeURIComponent(subdomain)}/activity`, + { params: { offset, limit } }, + ); + return data; +} + // === SaaS-admin → Биллинг: aggregates пополнений/списаний === export interface ApiAdminBillingTenant { diff --git a/app/resources/js/components/admin/tenant-detail/TenantActivityPane.vue b/app/resources/js/components/admin/tenant-detail/TenantActivityPane.vue new file mode 100644 index 00000000..ba12d3f9 --- /dev/null +++ b/app/resources/js/components/admin/tenant-detail/TenantActivityPane.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/app/resources/js/components/admin/tenant-detail/TenantDetailTabs.vue b/app/resources/js/components/admin/tenant-detail/TenantDetailTabs.vue index 180d510b..e0a76273 100644 --- a/app/resources/js/components/admin/tenant-detail/TenantDetailTabs.vue +++ b/app/resources/js/components/admin/tenant-detail/TenantDetailTabs.vue @@ -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': []; }>(); @@ -75,7 +77,7 @@ const emit = defineEmits<{ Пользователь Email Роль - Последняя активность + Последний вход Статус @@ -86,7 +88,9 @@ const emit = defineEmits<{ {{ roleLabel(u.role) }} - {{ formatDate(u.last_active_at) }} + + {{ u.last_login_at !== '' ? formatDate(u.last_login_at) : '—' }} + {{ u.is_active ? 'активен' : 'отключён' }} @@ -126,20 +130,13 @@ const emit = defineEmits<{ -
-
    -
  • -
    {{ formatDate(ev.created_at) }}
    -
    -
    - {{ ev.event }} - · {{ ev.actor }} -
    -
    {{ ev.summary }}
    -
    -
  • -
-
+ @@ -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; -} diff --git a/app/resources/js/components/admin/tenants/TenantsTable.vue b/app/resources/js/components/admin/tenants/TenantsTable.vue index c06ffc9f..78462195 100644 --- a/app/resources/js/components/admin/tenants/TenantsTable.vue +++ b/app/resources/js/components/admin/tenants/TenantsTable.vue @@ -1,5 +1,6 @@ @@ -88,7 +97,12 @@ defineExpose({ tenant, activeTab, impersonationOpen, balanceDialogOpen, loadTena @impersonate="impersonationOpen = true" @edit-balance="balanceDialogOpen = true" /> - + 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)); diff --git a/app/routes/web.php b/app/routes/web.php index ac3be9f1..92c899f6 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -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'); diff --git a/app/tests/Feature/Admin/AdminTenantActivityFeedTest.php b/app/tests/Feature/Admin/AdminTenantActivityFeedTest.php new file mode 100644 index 00000000..82a73a6d --- /dev/null +++ b/app/tests/Feature/Admin/AdminTenantActivityFeedTest.php @@ -0,0 +1,166 @@ +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); +}); diff --git a/app/tests/Feature/Admin/AdminTenantAlivenessTest.php b/app/tests/Feature/Admin/AdminTenantAlivenessTest.php new file mode 100644 index 00000000..d289d073 --- /dev/null +++ b/app/tests/Feature/Admin/AdminTenantAlivenessTest.php @@ -0,0 +1,107 @@ +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([]); +}); diff --git a/app/tests/Feature/Admin/AdminTenantsListActivityTest.php b/app/tests/Feature/Admin/AdminTenantsListActivityTest.php new file mode 100644 index 00000000..bb0bc8cd --- /dev/null +++ b/app/tests/Feature/Admin/AdminTenantsListActivityTest.php @@ -0,0 +1,80 @@ +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); +}); diff --git a/app/tests/Feature/AdminTenantShowTest.php b/app/tests/Feature/AdminTenantShowTest.php index 944748b8..0ec31981 100644 --- a/app/tests/Feature/AdminTenantShowTest.php +++ b/app/tests/Feature/AdminTenantShowTest.php @@ -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 () { diff --git a/app/tests/Feature/AdminTenantsIndexTest.php b/app/tests/Feature/AdminTenantsIndexTest.php index db1b9e87..4c73a92e 100644 --- a/app/tests/Feature/AdminTenantsIndexTest.php +++ b/app/tests/Feature/AdminTenantsIndexTest.php @@ -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(); diff --git a/app/tests/Feature/ClientSms/QuietHoursVtoroeOknoTest.php b/app/tests/Feature/ClientSms/QuietHoursVtoroeOknoTest.php new file mode 100644 index 00000000..a055954d --- /dev/null +++ b/app/tests/Feature/ClientSms/QuietHoursVtoroeOknoTest.php @@ -0,0 +1,235 @@ +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); +}); diff --git a/app/tests/Feature/Lena/StorozhaMashinyTest.php b/app/tests/Feature/Lena/StorozhaMashinyTest.php new file mode 100644 index 00000000..46ce007b --- /dev/null +++ b/app/tests/Feature/Lena/StorozhaMashinyTest.php @@ -0,0 +1,441 @@ +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')"); +}); diff --git a/app/tests/Feature/Obzvon/ObzvonClientShovTest.php b/app/tests/Feature/Obzvon/ObzvonClientShovTest.php new file mode 100644 index 00000000..8a271147 --- /dev/null +++ b/app/tests/Feature/Obzvon/ObzvonClientShovTest.php @@ -0,0 +1,974 @@ +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'"); +}); diff --git a/app/tests/Feature/Obzvon/ObzvonOknoChasovTest.php b/app/tests/Feature/Obzvon/ObzvonOknoChasovTest.php new file mode 100644 index 00000000..21d0d3e8 --- /dev/null +++ b/app/tests/Feature/Obzvon/ObzvonOknoChasovTest.php @@ -0,0 +1,177 @@ +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); +}); diff --git a/app/tests/Feature/Obzvon/ObzvonZapisMimoDveriTest.php b/app/tests/Feature/Obzvon/ObzvonZapisMimoDveriTest.php new file mode 100644 index 00000000..2b5b415f --- /dev/null +++ b/app/tests/Feature/Obzvon/ObzvonZapisMimoDveriTest.php @@ -0,0 +1,579 @@ +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' +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' +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' +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' +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' +where('id', 1) + ->update(['phone' => '+70000000000']); + } +} +PHP); + + try { + expect(mimoDveriNajti($vremennaya))->toBe([]); + } finally { + @unlink($fayl); + @rmdir($vremennaya); + } +}); + +/** + * Обход кода в поисках записи исхода мимо объекта модели. + * + * 🔴 Белого списка «кому можно» здесь НЕТ и быть не должно. Дверь пишет через + * объект модели, а не массовой правкой, поэтому запрет действует на ВСЕХ без + * исключений — включая саму дверь. Список разрешённых имён сам стал бы дырой. + * + * @return list найденные места в виде «файл:строка — кусок» + */ +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 + */ +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; +} diff --git a/app/tests/Feature/Security/KlyuchNeEdetPoPereadresaciiTest.php b/app/tests/Feature/Security/KlyuchNeEdetPoPereadresaciiTest.php new file mode 100644 index 00000000..0140dd9d --- /dev/null +++ b/app/tests/Feature/Security/KlyuchNeEdetPoPereadresaciiTest.php @@ -0,0 +1,239 @@ + Http::response('', 302, ['Location' => 'http://'.CHUZHAYA.'/ukradi']), + CHUZHAYA.'/*' => Http::response(['data' => 'ukradeno', 'ok' => true, 'ip' => '1.2.3.4'], 200), + ]); +} + +/** + * Записи всех обращений, дошедших до ЧУЖОЙ машины, — с указанием, вёз ли запрос тайный ключ. + * + * @return list + */ +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(''); +}); diff --git a/app/tests/Frontend/AdminTenantDetailView.spec.ts b/app/tests/Frontend/AdminTenantDetailView.spec.ts index 351d6457..8b5c7c32 100644 --- a/app/tests/Frontend/AdminTenantDetailView.spec.ts +++ b/app/tests/Frontend/AdminTenantDetailView.spec.ts @@ -37,6 +37,7 @@ function makeApiDetail(overrides: Partial = {}): 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 = {}): 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).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).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).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'); + }); }); diff --git a/app/tests/Frontend/AdminTenantsView.spec.ts b/app/tests/Frontend/AdminTenantsView.spec.ts index 9ff20df0..664b72a3 100644 --- a/app/tests/Frontend/AdminTenantsView.spec.ts +++ b/app/tests/Frontend/AdminTenantsView.spec.ts @@ -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'); + }); }); diff --git a/app/tests/Frontend/AdminTenantsViewApi.spec.ts b/app/tests/Frontend/AdminTenantsViewApi.spec.ts index 51a6aeea..53188f8f 100644 --- a/app/tests/Frontend/AdminTenantsViewApi.spec.ts +++ b/app/tests/Frontend/AdminTenantsViewApi.spec.ts @@ -34,6 +34,7 @@ function makeApiTenant(overrides: Partial = {}): 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', () => { diff --git a/app/tests/Frontend/TenantActivityPane.spec.ts b/app/tests/Frontend/TenantActivityPane.spec.ts new file mode 100644 index 00000000..d0e87feb --- /dev/null +++ b/app/tests/Frontend/TenantActivityPane.spec.ts @@ -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); + }); +}); diff --git a/app/tests/Frontend/adminTenantDetailMapper.spec.ts b/app/tests/Frontend/adminTenantDetailMapper.spec.ts index ec522179..737702ff 100644 --- a/app/tests/Frontend/adminTenantDetailMapper.spec.ts +++ b/app/tests/Frontend/adminTenantDetailMapper.spec.ts @@ -20,11 +20,21 @@ function makeApi(overrides: Partial = {}): 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); + }); +}); diff --git a/app/tests/Frontend/proxyMarketRow.spec.ts b/app/tests/Frontend/proxyMarketRow.spec.ts index 9e810178..a530abf8 100644 --- a/app/tests/Frontend/proxyMarketRow.spec.ts +++ b/app/tests/Frontend/proxyMarketRow.spec.ts @@ -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')); + }); }); diff --git a/app/tests/Frontend/tenantActivityFormatters.spec.ts b/app/tests/Frontend/tenantActivityFormatters.spec.ts new file mode 100644 index 00000000..95e3cf59 --- /dev/null +++ b/app/tests/Frontend/tenantActivityFormatters.spec.ts @@ -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 { + 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('нет данных'); + }); +}); diff --git a/app/tests/Unit/Autopodbor/Fetch/DverRenderaTest.php b/app/tests/Unit/Autopodbor/Fetch/DverRenderaTest.php new file mode 100644 index 00000000..d98cf62d --- /dev/null +++ b/app/tests/Unit/Autopodbor/Fetch/DverRenderaTest.php @@ -0,0 +1,261 @@ +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')); +}); diff --git a/app/tests/Unit/Autopodbor/Fetch/RenderServiceYandexListSourceTest.php b/app/tests/Unit/Autopodbor/Fetch/RenderServiceYandexListSourceTest.php index 2ce9ace4..5e2df74c 100644 --- a/app/tests/Unit/Autopodbor/Fetch/RenderServiceYandexListSourceTest.php +++ b/app/tests/Unit/Autopodbor/Fetch/RenderServiceYandexListSourceTest.php @@ -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) diff --git a/app/tests/Unit/Autopodbor/Fetch/SelfRenderClientTest.php b/app/tests/Unit/Autopodbor/Fetch/SelfRenderClientTest.php index d5030648..dc0e410b 100644 --- a/app/tests/Unit/Autopodbor/Fetch/SelfRenderClientTest.php +++ b/app/tests/Unit/Autopodbor/Fetch/SelfRenderClientTest.php @@ -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'], ); } diff --git a/app/tests/Unit/External/ExternalBalanceRefresherTest.php b/app/tests/Unit/External/ExternalBalanceRefresherTest.php index 0896ec82..8ec2da2a 100644 --- a/app/tests/Unit/External/ExternalBalanceRefresherTest.php +++ b/app/tests/Unit/External/ExternalBalanceRefresherTest.php @@ -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(); diff --git a/app/tests/Unit/External/ProxyMarketProbeDverTest.php b/app/tests/Unit/External/ProxyMarketProbeDverTest.php new file mode 100644 index 00000000..bec4ea73 --- /dev/null +++ b/app/tests/Unit/External/ProxyMarketProbeDverTest.php @@ -0,0 +1,200 @@ +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'], +]); diff --git a/app/tests/Unit/External/ProxyMarketProbeTest.php b/app/tests/Unit/External/ProxyMarketProbeTest.php index cad860c7..85426dfe 100644 --- a/app/tests/Unit/External/ProxyMarketProbeTest.php +++ b/app/tests/Unit/External/ProxyMarketProbeTest.php @@ -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 () { diff --git a/bots/lena-golos/README.md b/bots/lena-golos/README.md index b5dec6cb..23b53f14 100644 --- a/bots/lena-golos/README.md +++ b/bots/lena-golos/README.md @@ -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` из + своей папки, а проба приёмника — не сторож уборки чужих записей. Захотите + включить — это три шага и подтверждение числа сторожей на портале, см. выше. + + + ## Как сверить копию настройки с сервером `extensions-lena.conf` — копия `/etc/asterisk/extensions.conf` с сервера diff --git a/bots/lena-golos/lena-priyomnik.service b/bots/lena-golos/lena-priyomnik.service new file mode 100644 index 00000000..bab3a267 --- /dev/null +++ b/bots/lena-golos/lena-priyomnik.service @@ -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 diff --git a/bots/lena-golos/priyomnik.py b/bots/lena-golos/priyomnik.py new file mode 100644 index 00000000..1f01e402 --- /dev/null +++ b/bots/lena-golos/priyomnik.py @@ -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() diff --git a/bots/lena-golos/proba-priyomnika.py b/bots/lena-golos/proba-priyomnika.py new file mode 100644 index 00000000..9d67d9ee --- /dev/null +++ b/bots/lena-golos/proba-priyomnika.py @@ -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) diff --git a/bots/lena-golos/proverka-raspisaniya.sh b/bots/lena-golos/proverka-raspisaniya.sh index 43bebc26..6eb0a212 100644 --- a/bots/lena-golos/proverka-raspisaniya.sh +++ b/bots/lena-golos/proverka-raspisaniya.sh @@ -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 diff --git a/bots/lena-golos/proverka-sutochnogo-obhoda.sh b/bots/lena-golos/proverka-sutochnogo-obhoda.sh new file mode 100644 index 00000000..03363611 --- /dev/null +++ b/bots/lena-golos/proverka-sutochnogo-obhoda.sh @@ -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" </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 diff --git a/bots/lena-golos/proverka-tekstovogo-sleda.sh b/bots/lena-golos/proverka-tekstovogo-sleda.sh index 230f7d0b..fa3f3a8f 100644 --- a/bots/lena-golos/proverka-tekstovogo-sleda.sh +++ b/bots/lena-golos/proverka-tekstovogo-sleda.sh @@ -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 diff --git a/bots/lena-golos/sutochnyy-obhod.cron b/bots/lena-golos/sutochnyy-obhod.cron new file mode 100644 index 00000000..caa822a3 --- /dev/null +++ b/bots/lena-golos/sutochnyy-obhod.cron @@ -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 diff --git a/bots/lena-golos/sutochnyy-obhod.sh b/bots/lena-golos/sutochnyy-obhod.sh new file mode 100644 index 00000000..c1e655fa --- /dev/null +++ b/bots/lena-golos/sutochnyy-obhod.sh @@ -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" <>"$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 diff --git a/cspell-words.txt b/cspell-words.txt index 58b04322..8f74c4d1 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -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 +Бенто +мудборд +запускальщик +Снимальщик diff --git a/docs/grilling/2026-08-03-metodika-lena-pod-klienta.md b/docs/grilling/2026-08-03-metodika-lena-pod-klienta.md index 3d70f83f..a0ca4dce 100644 --- a/docs/grilling/2026-08-03-metodika-lena-pod-klienta.md +++ b/docs/grilling/2026-08-03-metodika-lena-pod-klienta.md @@ -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.** Разговор от **одной** секунды. diff --git a/docs/grilling/2026-08-06-lending-peredelka.md b/docs/grilling/2026-08-06-lending-peredelka.md index b5977ab7..e18a17b4 100644 --- a/docs/grilling/2026-08-06-lending-peredelka.md +++ b/docs/grilling/2026-08-06-lending-peredelka.md @@ -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...». Моноширинный, сиреневый, малый кегль. + +**Чего в эталоне НЕТ вообще:** шапки, меню-строки, подвала, карточек, сеток, +секций, кнопок «наверх», разделителей. Всё содержание живёт объектами внутри сцены. + +**Главный вывод, объясняющий четыре отклонённые сборки:** зеркальный материал в +эталоне применён **скупо — на одном предмете** (кольцо). Основная красота держится не +на хроме, а на **видео внутри панелей**, **объёмных облаках точек** и **построенной +комнате с отражающим полом**. Мы четыре раза делали ставку на хром и получали белую +ленту, а панели оставляли пустыми — то есть повторяли самое дешёвое и пропускали самое +дорогое. diff --git a/docs/lending-sborka/00-PROMT-NOVOY-SESSII.md b/docs/lending-sborka/00-PROMT-NOVOY-SESSII.md new file mode 100644 index 00000000..6fe1f1b9 --- /dev/null +++ b/docs/lending-sborka/00-PROMT-NOVOY-SESSII.md @@ -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 +``` diff --git a/docs/lending-sborka/01-produkt-chto-prodayom.md b/docs/lending-sborka/01-produkt-chto-prodayom.md new file mode 100644 index 00000000..73ae5264 --- /dev/null +++ b/docs/lending-sborka/01-produkt-chto-prodayom.md @@ -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. Личные данные + +В сцене не может быть ни одного настоящего телефона, имени, названия компании или +номера сделки. Только выдуманные образцы. diff --git a/docs/lending-sborka/02-etalon-razbor-po-kadram.md b/docs/lending-sborka/02-etalon-razbor-po-kadram.md new file mode 100644 index 00000000..e5efc637 --- /dev/null +++ b/docs/lending-sborka/02-etalon-razbor-po-kadram.md @@ -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 (текст). Оба с кириллицей. + +🔑 **Главный образ картинки — «точки → кольцо → воронка → капли → кабинет» — это +готовый сценарий полёта камеры.** Он уже утверждён владельцем и не требует выдумывания. diff --git a/docs/lending-sborka/03-resursy-vneshnie-modeli.md b/docs/lending-sborka/03-resursy-vneshnie-modeli.md new file mode 100644 index 00000000..c4e53169 --- /dev/null +++ b/docs/lending-sborka/03-resursy-vneshnie-modeli.md @@ -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/ +→ статус 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`) — ходить прямым +запросом. diff --git a/docs/lending-sborka/04-cepochka-vkusa.md b/docs/lending-sborka/04-cepochka-vkusa.md new file mode 100644 index 00000000..b82552ea --- /dev/null +++ b/docs/lending-sborka/04-cepochka-vkusa.md @@ -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 и ломать однообразие +заново. diff --git a/docs/lending-sborka/05-pravila-promtov-subagentam.md b/docs/lending-sborka/05-pravila-promtov-subagentam.md new file mode 100644 index 00000000..a2df95a9 --- /dev/null +++ b/docs/lending-sborka/05-pravila-promtov-subagentam.md @@ -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. Задать один вопрос: **«это мир, по которому летит камера, или страница с + трёхмерной подложкой?»** Если второе — переделка, сколько бы галочек ни стояло. diff --git a/docs/lending-sborka/06-tehnicheskaya-baza-dokazannoe.md b/docs/lending-sborka/06-tehnicheskaya-baza-dokazannoe.md new file mode 100644 index 00000000..db0f47a4 --- /dev/null +++ b/docs/lending-sborka/06-tehnicheskaya-baza-dokazannoe.md @@ -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 + +``` + +```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 секунд, вышло с первого раза. diff --git a/docs/lending-sborka/07-izolyaciya-chto-ne-brat.md b/docs/lending-sborka/07-izolyaciya-chto-ne-brat.md new file mode 100644 index 00000000..454a7bae --- /dev/null +++ b/docs/lending-sborka/07-izolyaciya-chto-ne-brat.md @@ -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. Как проверить себя одним вопросом + +> **Это мир, по которому летит камера, — или страница с трёхмерной подложкой?** + +Если ответ второй, никакое количество выполненных пунктов не спасает. Именно так +была отклонена четвёртая сборка со словами «ты по сути фон заменил». diff --git a/docs/lending-sborka/etalon/kadr-01-pustota.jpg b/docs/lending-sborka/etalon/kadr-01-pustota.jpg new file mode 100644 index 00000000..015817ba Binary files /dev/null and b/docs/lending-sborka/etalon/kadr-01-pustota.jpg differ diff --git a/docs/lending-sborka/etalon/kadr-02-zagolovok-hrom.jpg b/docs/lending-sborka/etalon/kadr-02-zagolovok-hrom.jpg new file mode 100644 index 00000000..afdc1c12 Binary files /dev/null and b/docs/lending-sborka/etalon/kadr-02-zagolovok-hrom.jpg differ diff --git a/docs/lending-sborka/etalon/kadr-03-paneli-nachalo.jpg b/docs/lending-sborka/etalon/kadr-03-paneli-nachalo.jpg new file mode 100644 index 00000000..d01f122a Binary files /dev/null and b/docs/lending-sborka/etalon/kadr-03-paneli-nachalo.jpg differ diff --git a/docs/lending-sborka/etalon/kadr-04-paneli-blizko.jpg b/docs/lending-sborka/etalon/kadr-04-paneli-blizko.jpg new file mode 100644 index 00000000..6654adee Binary files /dev/null and b/docs/lending-sborka/etalon/kadr-04-paneli-blizko.jpg differ diff --git a/docs/lending-sborka/etalon/kadr-05-paneli-hogwarts.jpg b/docs/lending-sborka/etalon/kadr-05-paneli-hogwarts.jpg new file mode 100644 index 00000000..d4f22003 Binary files /dev/null and b/docs/lending-sborka/etalon/kadr-05-paneli-hogwarts.jpg differ diff --git a/docs/lending-sborka/etalon/kadr-06-komnata-lab.jpg b/docs/lending-sborka/etalon/kadr-06-komnata-lab.jpg new file mode 100644 index 00000000..60a2536f Binary files /dev/null and b/docs/lending-sborka/etalon/kadr-06-komnata-lab.jpg differ diff --git a/docs/lending-sborka/etalon/list-32-kadra.jpg b/docs/lending-sborka/etalon/list-32-kadra.jpg new file mode 100644 index 00000000..4ae1da4e Binary files /dev/null and b/docs/lending-sborka/etalon/list-32-kadra.jpg differ diff --git a/docs/lending-sborka/etalon/reklamnaya-kartinka-vladelca.png b/docs/lending-sborka/etalon/reklamnaya-kartinka-vladelca.png new file mode 100644 index 00000000..d54856c8 Binary files /dev/null and b/docs/lending-sborka/etalon/reklamnaya-kartinka-vladelca.png differ diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 34b87084..8b62cb5a 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -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-сессий. diff --git a/docs/superpowers/2026-08-06-KARTINA-RESURSOV-lending.md b/docs/superpowers/2026-08-06-KARTINA-RESURSOV-lending.md new file mode 100644 index 00000000..3b8767fd --- /dev/null +++ b/docs/superpowers/2026-08-06-KARTINA-RESURSOV-lending.md @@ -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. **Цены остальных моделей** — таблица цен закрыта веб-сессией, каждая цена меряется + отдельным запуском. diff --git a/docs/superpowers/2026-08-06-PROMT-lending-prodolzhenie-genapi.md b/docs/superpowers/2026-08-06-PROMT-lending-prodolzhenie-genapi.md new file mode 100644 index 00000000..882992fd --- /dev/null +++ b/docs/superpowers/2026-08-06-PROMT-lending-prodolzhenie-genapi.md @@ -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/` | +| Свой счёт | `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/` раз в 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. Только после этого переписывать три документа и что-либо строить. diff --git a/docs/superpowers/2026-08-07-PROMT-aktivnost-tenanta.md b/docs/superpowers/2026-08-07-PROMT-aktivnost-tenanta.md new file mode 100644 index 00000000..a81a0648 --- /dev/null +++ b/docs/superpowers/2026-08-07-PROMT-aktivnost-tenanta.md @@ -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`. Гринвичская + полночь — это три часа ночи по Москве, на этом уже обжигались. + +## Чего в этой работе НЕ делаем + +- Не пишем просмотры страниц — нечем. +- Не удаляем мёртвые колонки из схемы БД — отдельная уборка со своей записью + в журнале схемы. +- Не трогаем вкладку «Финансы». +- Не выкатываем на боевой. diff --git a/docs/superpowers/2026-08-08-PROMT-obzvon-stroyka-2.md b/docs/superpowers/2026-08-08-PROMT-obzvon-stroyka-2.md index 7e47ed1b..5a98f154 100644 --- a/docs/superpowers/2026-08-08-PROMT-obzvon-stroyka-2.md +++ b/docs/superpowers/2026-08-08-PROMT-obzvon-stroyka-2.md @@ -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 и тобой прошла **не стройка, а разбор развилок с владельцем**: кода никто не трогал, но решений стало на **одиннадцать** больше, задач в плане на **три** больше, и у стройки появился diff --git a/docs/superpowers/2026-08-09-PROMT-obzvon-stroyka-3.md b/docs/superpowers/2026-08-09-PROMT-obzvon-stroyka-3.md new file mode 100644 index 00000000..468332b8 --- /dev/null +++ b/docs/superpowers/2026-08-09-PROMT-obzvon-stroyka-3.md @@ -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/`, обе в игноре. +- **Открытые вопросы владельца не закрывать** без явного «закрываем». + +**СТИЛЬ:** простой русский, без программистских слов — владелец не программист. +**Не называть сделанным непроверенное. Свою ошибку называть первой.** +**И работать строго по методу — это прямое требование владельца.** diff --git a/docs/superpowers/2026-08-10-PROMT-obzvon-stroyka-4.md b/docs/superpowers/2026-08-10-PROMT-obzvon-stroyka-4.md new file mode 100644 index 00000000..8f7f33bc --- /dev/null +++ b/docs/superpowers/2026-08-10-PROMT-obzvon-stroyka-4.md @@ -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/`, обе в игноре. +- **Открытые вопросы владельца не закрывать** без явного «закрываем». + +**СТИЛЬ:** простой русский, без программистских слов — владелец не программист. +**Не называть сделанным непроверенное. Свою ошибку называть первой.** +**И работать строго по методу — это прямое требование владельца.** diff --git a/docs/superpowers/findings/2026-08-06-genapi-nastoyashchie-nazvaniya-modeley.md b/docs/superpowers/findings/2026-08-06-genapi-nastoyashchie-nazvaniya-modeley.md new file mode 100644 index 00000000..f3b8bd0c --- /dev/null +++ b/docs/superpowers/findings/2026-08-06-genapi-nastoyashchie-nazvaniya-modeley.md @@ -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`. diff --git a/docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md b/docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md index b6dae87e..6ce5ca6a 100644 --- a/docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md +++ b/docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md @@ -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. Задача разблокирована целиком** — в первой редакции она была наполовину замороженной. diff --git a/docs/superpowers/plans/2026-08-07-aktivnost-tenanta-zhiv-li-klient.md b/docs/superpowers/plans/2026-08-07-aktivnost-tenanta-zhiv-li-klient.md new file mode 100644 index 00000000..7d0a0635 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-aktivnost-tenanta-zhiv-li-klient.md @@ -0,0 +1,2361 @@ +# Вкладка «Активность» — «жив ли клиент». План работ + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Переделать вкладку «Активность» в карточке клиента админки так, чтобы она одним взглядом отвечала «жив ли клиент», и оживить мёртвую сортировку списка клиентов по последней активности. + +**Architecture:** Два новых сервиса на бэкенде считают всё на лету из журналов (`auth_log`, `tenant_operations_log`, `activity_log`, `balance_transactions`, `saas_invoices`) — в базу ничего не пишем, миграций нет. Первый сервис отдаёт вердикт «жив/остывает/пропал», полоску 90 дней и четыре счётчика; второй собирает ленту дел одним объединяющим запросом с постраничной подгрузкой. Фронт получает структурные события и складывает из них русские фразы. + +**Tech Stack:** PHP 8.3 / Laravel 13 / PostgreSQL 16 (партиционированные журналы) / Pest 4 · Vue 3 + Vuetify 3 / Vitest + +**Спека:** [docs/superpowers/specs/2026-08-07-aktivnost-tenanta-zhiv-li-klient-design.md](../specs/2026-08-07-aktivnost-tenanta-zhiv-li-klient-design.md) + +--- + +## Карта файлов + +**Создаём:** + +| Файл | За что отвечает | +|---|---| +| `app/app/Services/Admin/TenantAlivenessService.php` | Вердикт, полоска 90 дней, четыре счётчика. Один класс — один вопрос «жив ли». | +| `app/app/Services/Admin/TenantActivityFeedService.php` | Лента дел из пяти источников + счётчик дел за период. Держит единственную копию объединяющего SQL. | +| `app/resources/js/components/admin/tenant-detail/TenantActivityPane.vue` | Вся панель «Активность»: верхний блок, полоска, лента, кнопка «показать ещё». | +| `app/resources/js/composables/tenantActivityFormatters.ts` | Русские фразы событий, расшифровка устройства, подписи вердикта. | +| `app/tests/Feature/Admin/AdminTenantAlivenessTest.php` | Пороги, московские сутки, пустой журнал. | +| `app/tests/Feature/Admin/AdminTenantActivityFeedTest.php` | Состав ленты, отсутствие системного шума, постраничность. | +| `app/tests/Feature/Admin/AdminTenantsListActivityTest.php` | Сортировка списка и число запросов. | +| `app/tests/Frontend/tenantActivityFormatters.spec.ts` | Фразы и устройство. | +| `app/tests/Frontend/TenantActivityPane.spec.ts` | Отрисовка панели. | + +**Меняем:** + +| Файл | Что | +|---|---| +| `app/app/Http/Controllers/Api/AdminTenantsController.php` | `show` отдаёт `aliveness` и новую `activity`; `index` считает последний вход и состояние; новый метод `activity` для подгрузки. | +| `app/routes/web.php:178` (рядом) | Новый маршрут подгрузки ленты. | +| `app/resources/js/api/admin.ts` | Типы `ApiTenantAliveness`, `ApiTenantActivityItem`, функция `getAdminTenantActivity`. | +| `app/resources/js/composables/adminTenantDetailMapper.ts` | Разбор новых полей вместо старого `mapActivity`. | +| `app/resources/js/composables/mockTenantDetail.ts` | Типы `TenantAliveness`, `TenantActivityItem` вместо `TenantActivityEvent`. | +| `app/resources/js/composables/mockTenants.ts` | Поле `alivenessState` в `AdminTenant`. | +| `app/resources/js/composables/adminTenantsMapper.ts` | Проброс состояния и текста последней активности. | +| `app/resources/js/components/admin/tenants/TenantsTable.vue` | Цветная точка состояния рядом с колонкой активности. | +| `app/resources/js/components/admin/tenant-detail/TenantDetailTabs.vue` | Панель `activity` заменяется на ``; колонка на вкладке «Пользователи» переключается на «Последний вход». | +| `app/resources/js/views/admin/AdminTenantDetailView.vue` | Проброс `aliveness` и обработчик подгрузки. | + +--- + +## Договорённости об именах (нарушать нельзя — на них завязаны все задачи) + +Состояния (одна строка, приходит с бэкенда): + +``` +'active' заходил ≤ 3 суток назад зелёный +'cooling' 4–13 суток жёлтый +'lost' ≥ 14 суток красный +'never_logged_in' есть регистрация, входов ноль красный +'no_data' в журнале нет вообще ничего серый +``` + +Форма блока `aliveness` в ответе `GET /api/admin/tenants/{subdomain}`: + +```json +{ + "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-05", "visits": 1 }, { "date": "2026-08-06", "visits": 1 }], + "counters": { "visits_30d": 2, "active_days_30d": 2, "actions_30d": 2, "failed_logins_30d": 0 } +} +``` + +`days` содержит только сутки, в которых визиты были. Пустые сутки фронт достраивает сам, зная окно в 90 дней. + +Форма элемента ленты: + +```json +{ + "id": "ops-1234", + "at": "2026-08-05T07:34:48+00:00", + "kind": "project", + "event": "project.created", + "actor": "ekb96@well.ru", + "subject": "Окна ЕКБ", + "amount_rub": null, + "bulk_count": 0 +} +``` + +`kind` — `project` | `deal` | `money`. `id` уникален между источниками за счёт приставки: `ops-`, `act-`, `tx-`, `inv-{id}-issued`, `inv-{id}-paid`. + +--- + +## Задача 1: Сервис «жив ли клиент» — вердикт и пороги + +**Files:** +- Create: `app/app/Services/Admin/TenantAlivenessService.php` +- Test: `app/tests/Feature/Admin/AdminTenantAlivenessTest.php` + +- [ ] **Шаг 1: Написать падающий тест на пороги** + +Создать `app/tests/Feature/Admin/AdminTenantAlivenessTest.php`: + +```php +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([]); +}); +``` + +- [ ] **Шаг 2: Запустить тест и убедиться, что он падает** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantAlivenessTest.php +``` + +Ожидаемо: падение с `Target class [App\Services\Admin\TenantAlivenessService] does not exist.` + +- [ ] **Шаг 3: Написать сервис** + +Создать `app/app/Services/Admin/TenantAlivenessService.php`: + +```php +, + * 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($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 */ + 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), + ]; + } +} +``` + +- [ ] **Шаг 4: Запустить тест — он всё ещё падает на отсутствии второго сервиса** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantAlivenessTest.php +``` + +Ожидаемо: `Class "App\Services\Admin\TenantActivityFeedService" not found`. Это нормально — сервис ленты делается задачей 2. Не чинить заглушкой, идти дальше. + +--- + +## Задача 2: Сервис ленты дел + +**Files:** +- Create: `app/app/Services/Admin/TenantActivityFeedService.php` +- Test: `app/tests/Feature/Admin/AdminTenantActivityFeedTest.php` + +- [ ] **Шаг 1: Написать падающий тест** + +Создать `app/tests/Feature/Admin/AdminTenantActivityFeedTest.php`: + +```php +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); +}); +``` + +- [ ] **Шаг 2: Запустить тест и убедиться, что он падает** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantActivityFeedTest.php +``` + +Ожидаемо: `Target class [App\Services\Admin\TenantActivityFeedService] does not exist.` + +- [ ] **Шаг 3: Написать сервис** + +Создать `app/app/Services/Admin/TenantActivityFeedService.php`: + +```php + + */ + 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 */ + private function unionBindings(int $tenantId): array + { + return [$tenantId, $tenantId, $tenantId, $tenantId, $tenantId]; + } +} +``` + +- [ ] **Шаг 4: Запустить оба теста — должны пройти** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantActivityFeedTest.php tests/Feature/Admin/AdminTenantAlivenessTest.php +``` + +Ожидаемо: все зелёные. Если `AdminTenantAlivenessTest` всё ещё красный — читать сообщение, а не править тест. + +- [ ] **Шаг 5: Проверить, что сторож умеет краснеть** + +Временно поменять в `TenantActivityFeedService::unionSql()` условие второго куска +`WHERE a.tenant_id = ? AND a.user_id IS NOT NULL` на `WHERE a.tenant_id = ?` +и прогнать тесты. + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantActivityFeedTest.php +``` + +Ожидаемо: КРАСНОЕ на «пускает ручную работу со сделками и НЕ пускает системную» — +получено 2 вместо 1. Вернуть условие обратно, убедиться, что снова зелено. + +Зелёный сторож, которого не видели красным, ничего не охраняет — на этом +в проекте уже обжигались. + +- [ ] **Шаг 6: Коммит** + +```bash +git add app/app/Services/Admin/ app/tests/Feature/Admin/AdminTenantAlivenessTest.php app/tests/Feature/Admin/AdminTenantActivityFeedTest.php +git commit -m "feat: сервисы «жив ли клиент» и ленты дел для карточки клиента" +``` + +--- + +## Задача 3: Подключить сервисы к карточке клиента + +**Files:** +- Modify: `app/app/Http/Controllers/Api/AdminTenantsController.php` (метод `show`, метод `fetchActivity` удаляется, добавляется метод `activity`) +- Modify: `app/routes/web.php` (рядом со строкой 178) +- Test: `app/tests/Feature/Admin/AdminTenantActivityFeedTest.php` (дописываем) + +- [ ] **Шаг 1: Дописать падающий тест на ответ ручки** + +Добавить в конец `app/tests/Feature/Admin/AdminTenantActivityFeedTest.php`: + +```php +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); +}); +``` + +- [ ] **Шаг 2: Запустить и убедиться, что падает** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantActivityFeedTest.php +``` + +Ожидаемо: два падения — в первом ответе нет ключа `aliveness`, второй маршрут отдаёт 404. + +- [ ] **Шаг 3: Добавить маршрут** + +В `app/routes/web.php` сразу после строки с `AdminTenantsController@show` (строка 178) дописать: + +```php + Route::get('/api/admin/tenants/{subdomain}/activity', 'App\Http\Controllers\Api\AdminTenantsController@activity') + ->where('subdomain', '[A-Za-z0-9._-]+'); +``` + +Ограничение `where` повторяет соседний маршрут `show` — иначе точка в поддомене съест часть пути. + +- [ ] **Шаг 4: Переписать `show` и добавить `activity`** + +В `app/app/Http/Controllers/Api/AdminTenantsController.php`: + +добавить в начало файла к остальным `use`: + +```php +use App\Services\Admin\TenantActivityFeedService; +use App\Services\Admin\TenantAlivenessService; +``` + +добавить константу в тело класса, рядом с другими константами: + +```php + /** Сколько дел отдаём одной страницей ленты. */ + private const ACTIVITY_PAGE = 50; +``` + +в методе `show` заменить строку + +```php + 'activity' => $this->fetchActivity($tenantId), +``` + +на + +```php + 'aliveness' => app(TenantAlivenessService::class)->forTenant($tenantId), + 'activity' => $this->activityPage($tenantId, self::ACTIVITY_PAGE, 0), +``` + +удалить метод `fetchActivity` целиком (он читал только журнал сделок — тот самый, где 87% системного шума) и добавить вместо него: + +```php + /** + * GET /api/admin/tenants/{subdomain}/activity — подгрузка следующей страницы + * ленты дел («показать ещё»). Отдельная ручка, чтобы не пересобирать всю + * карточку ради пятидесяти строк. + */ + public function activity(Request $request, string $subdomain): JsonResponse + { + $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>, 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), + ]; + } +``` + +- [ ] **Шаг 5: Запустить тесты** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantActivityFeedTest.php +``` + +Ожидаемо: все зелёные. + +- [ ] **Шаг 6: Прогнать соседние тесты карточки — вдруг что-то на неё опиралось** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/ +``` + +Ожидаемо: зелено. Если красное в чужом тесте, читающем `activity` как плоский массив, — это ожидаемое последствие смены формы ответа, чинить тест по новой форме. + +- [ ] **Шаг 7: Коммит** + +```bash +git add app/app/Http/Controllers/Api/AdminTenantsController.php app/routes/web.php app/tests/Feature/Admin/AdminTenantActivityFeedTest.php +git commit -m "feat: карточка клиента отдаёт «жив ли» и ленту дел вместо журнала сделок" +``` + +--- + +## Задача 4: Список клиентов — живая сортировка по последнему входу + +**Files:** +- Modify: `app/app/Http/Controllers/Api/AdminTenantsController.php` (метод `index`) +- Test: `app/tests/Feature/Admin/AdminTenantsListActivityTest.php` + +- [ ] **Шаг 1: Написать падающий тест** + +Создать `app/tests/Feature/Admin/AdminTenantsListActivityTest.php`: + +```php +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); +}); +``` + +- [ ] **Шаг 2: Запустить и убедиться, что падает** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantsListActivityTest.php +``` + +Ожидаемо: падение на отсутствии ключа `aliveness_state` и на порядке сортировки. + +- [ ] **Шаг 3: Переписать `index`** + +В `app/app/Http/Controllers/Api/AdminTenantsController.php`, метод `index`. + +Добавить перед сборкой `$query` (после чтения фильтров): + +```php + // Последний вход — из журнала входов. Колонка tenants.last_activity_at + // мертва: её никто не пишет (замерено 07.08.2026 — ноль из одиннадцати), + // и сортировка по ней работала вслепую. + $stripSince = MskBoundary::instant( + CarbonImmutable::now('Europe/Moscow') + ->subDays(TenantAlivenessService::STRIP_DAYS - 1) + ->startOfDay() + ); +``` + +Последний вход подставляется подзапросом прямо в `select` — тогда по его +псевдониму можно и сортировать, и не тащить в запрос ещё один join. + +Заменить строку + +```php + 'tenants.last_activity_at', +``` + +в списке `select` на + +```php + 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'), +``` + +и добавить привязку сразу после `->select([...])`: + +```php + ->addBinding($stripSince, 'select') +``` + +Заменить сортировку + +```php + ->orderByDesc('tenants.last_activity_at') +``` + +на + +```php + ->orderByRaw('last_activity_at DESC NULLS LAST') +``` + +В сборке ответа заменить + +```php + 'last_activity_at' => $t->last_activity_at !== null + ? CarbonImmutable::parse($t->last_activity_at)->toIso8601String() + : null, +``` + +на + +```php + 'last_activity_at' => $t->last_activity_at !== null + ? CarbonImmutable::parse($t->last_activity_at)->toIso8601String() + : null, + 'aliveness_state' => $this->listState($t->last_activity_at), +``` + +и добавить приватный метод в конец класса: + +```php + /** + * Состояние клиента для списка. Пустое значение означает «за девяносто дней + * входов не было» — для списка это то же самое, что «пропал»; тонкое различие + * «зарегистрировался и ни разу не вошёл» живёт в карточке, где для него есть + * место под подпись. + */ + private function listState(?string $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'; + } +``` + +Добавить к `use` в шапке файла, если ещё нет: + +```php +use App\Support\MskBoundary; +``` + +- [ ] **Шаг 4: Запустить тест** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantsListActivityTest.php +``` + +Ожидаемо: зелено. Если ругается на `?` внутри `DB::raw` в `select` — проверить, что `addBinding` вызван с типом `'select'` и стоит ПОСЛЕ `->select([...])`, но ДО применения фильтров `where`. + +- [ ] **Шаг 5: Проверить, что сторож умеет краснеть** + +Временно вернуть сортировку на `->orderByDesc('tenants.last_activity_at')` и прогнать. + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantsListActivityTest.php +``` + +Ожидаемо: КРАСНОЕ на первом тесте — порядок обратный. Вернуть `orderByRaw` обратно. + +- [ ] **Шаг 6: Прогнать весь блок админки** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/ +``` + +Ожидаемо: зелено. + +- [ ] **Шаг 7: Коммит** + +```bash +git add app/app/Http/Controllers/Api/AdminTenantsController.php app/tests/Feature/Admin/AdminTenantsListActivityTest.php +git commit -m "fix: список клиентов сортируется по живому журналу входов, а не по мёртвой колонке" +``` + +--- + +## Задача 5: Типы API и маппер на фронте + +**Files:** +- Modify: `app/resources/js/api/admin.ts:182-213` +- Modify: `app/resources/js/composables/mockTenantDetail.ts:37-43` +- Modify: `app/resources/js/composables/adminTenantDetailMapper.ts` +- Modify: `app/resources/js/composables/mockTenants.ts:11-24` +- Modify: `app/resources/js/composables/adminTenantsMapper.ts` +- Test: `app/tests/Frontend/adminTenantDetailMapper.spec.ts` + +- [ ] **Шаг 1: Написать падающий тест маппера** + +Добавить в конец `app/tests/Frontend/adminTenantDetailMapper.spec.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { mapAdminTenantDetail } from '../../resources/js/composables/adminTenantDetailMapper'; + +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', + }, + 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); + }); +}); +``` + +- [ ] **Шаг 2: Запустить и убедиться, что падает** + +```bash +cd app && npx vitest run tests/Frontend/adminTenantDetailMapper.spec.ts +``` + +Ожидаемо: падение — у `mapped` нет поля `aliveness`. + +- [ ] **Шаг 3: Заменить типы в `api/admin.ts`** + +Удалить `ApiTenantActivityEvent` (строки 182–189) и вписать на его место: + +```ts +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; + 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; +} +``` + +В `AdminTenantDetailResponse` заменить строку `activity: ApiTenantActivityEvent[];` на: + +```ts + aliveness: ApiTenantAliveness; + activity: ApiTenantActivityPage; +``` + +Дописать после `getAdminTenantDetail`: + +```ts +export async function getAdminTenantActivity( + subdomain: string, + offset: number, + limit = 50, +): Promise { + const { data } = await apiClient.get( + `/api/admin/tenants/${encodeURIComponent(subdomain)}/activity`, + { params: { offset, limit } }, + ); + return data; +} +``` + +В `AdminTenant` (тот, что описывает строку списка) дописать поле: + +```ts + aliveness_state: ApiAlivenessState; +``` + +- [ ] **Шаг 4: Заменить типы в `mockTenantDetail.ts`** + +Удалить `TenantActivityEvent` (строки 37–43), вписать: + +```ts +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; +} +``` + +В `AdminTenantDetail` заменить `activity: TenantActivityEvent[];` на: + +```ts + aliveness: TenantAliveness; + activity: TenantActivityItem[]; + activityHasMore: boolean; +``` + +- [ ] **Шаг 5: Переписать маппер карточки** + +В `app/resources/js/composables/adminTenantDetailMapper.ts` удалить функции `mapActivity` и `buildActivitySummary`, вписать вместо них: + +```ts +function mapAliveness(a: ApiTenantAliveness): TenantAliveness { + return { + 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, + }; +} + +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, + }; +} +``` + +Поправить импорты в шапке файла: убрать `ApiTenantActivityEvent` и `TenantActivityEvent`, добавить `ApiTenantAliveness`, `ApiTenantActivityItem`, `TenantAliveness`, `TenantActivityItem`. + +В `mapAdminTenantDetail` заменить строку `activity: api.activity.map(mapActivity),` на: + +```ts + aliveness: mapAliveness(api.aliveness), + activity: api.activity.items.map(mapTenantActivityItem), + activityHasMore: api.activity.has_more, +``` + +- [ ] **Шаг 6: Пробросить состояние в список клиентов** + +В `app/resources/js/composables/mockTenants.ts`, в `AdminTenant`, дописать поле: + +```ts + alivenessState: 'active' | 'cooling' | 'lost' | 'never_logged_in' | 'no_data'; +``` + +В `app/resources/js/composables/adminTenantsMapper.ts`, в возвращаемый объект `mapApiAdminTenant`, дописать: + +```ts + alivenessState: api.aliveness_state, +``` + +В том же файле поправить `formatRelative`, чтобы пустое значение читалось честно: + +```ts +function formatRelative(iso: string | null, now: Date = new Date()): string { + 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} мин назад`; + if (minutes < 60 * 24) return `${Math.floor(minutes / 60)} ч назад`; + return `${Math.floor(minutes / (60 * 24))} д назад`; +} +``` + +В `MOCK_TENANTS` (тот же файл `mockTenants.ts`) добавить `alivenessState: 'active'` каждому элементу — иначе не соберётся типизация. + +- [ ] **Шаг 7: Запустить тесты и проверку типов** + +```bash +cd app && npx vitest run tests/Frontend/adminTenantDetailMapper.spec.ts && npm run type-check +``` + +Ожидаемо: тест зелёный, проверка типов без ошибок. Если `type-check` ругается на `MOCK_TENANT_DETAIL` — дописать туда `aliveness`, `activity: []`, `activityHasMore: false` по форме из шага 4. + +- [ ] **Шаг 8: Коммит** + +```bash +git add app/resources/js/api/admin.ts app/resources/js/composables/ app/tests/Frontend/adminTenantDetailMapper.spec.ts +git commit -m "feat: типы и маппер под блок «жив ли клиент» и ленту дел" +``` + +--- + +## Задача 6: Русские фразы и расшифровка устройства + +**Files:** +- Create: `app/resources/js/composables/tenantActivityFormatters.ts` +- Test: `app/tests/Frontend/tenantActivityFormatters.spec.ts` + +- [ ] **Шаг 1: Написать падающий тест** + +Создать `app/tests/Frontend/tenantActivityFormatters.spec.ts`: + +```ts +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'; + +function item(patch: Partial): 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 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('нет данных'); + }); +}); +``` + +- [ ] **Шаг 2: Запустить и убедиться, что падает** + +```bash +cd app && npx vitest run tests/Frontend/tenantActivityFormatters.spec.ts +``` + +Ожидаемо: `Failed to resolve import ".../tenantActivityFormatters"`. + +- [ ] **Шаг 3: Написать форматтеры** + +Создать `app/resources/js/composables/tenantActivityFormatters.ts`: + +```ts +/** + * Фразы для вкладки «Активность» карточки клиента. + * + * Бэкенд отдаёт событие структурно (что за событие, над чем, на какую сумму), + * а человеческую фразу складываем здесь — рядом с форматированием рублей. + */ +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)}`; +} +``` + +- [ ] **Шаг 4: Запустить тест** + +```bash +cd app && npx vitest run tests/Frontend/tenantActivityFormatters.spec.ts +``` + +Ожидаемо: зелено. + +- [ ] **Шаг 5: Коммит** + +```bash +git add app/resources/js/composables/tenantActivityFormatters.ts app/tests/Frontend/tenantActivityFormatters.spec.ts +git commit -m "feat: русские фразы ленты, расшифровка устройства и подписи вердикта" +``` + +--- + +## Задача 7: Панель «Активность» — верх, полоска, лента + +**Files:** +- Create: `app/resources/js/components/admin/tenant-detail/TenantActivityPane.vue` +- Test: `app/tests/Frontend/TenantActivityPane.spec.ts` + +- [ ] **Шаг 1: Написать падающий тест** + +Создать `app/tests/Frontend/TenantActivityPane.spec.ts`: + +```ts +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import { createVuetify } from 'vuetify'; +import * as components from 'vuetify/components'; +import * as directives from 'vuetify/directives'; +import TenantActivityPane from '../../resources/js/components/admin/tenant-detail/TenantActivityPane.vue'; +import type { TenantActivityItem, TenantAliveness } from '../../resources/js/composables/mockTenantDetail'; + +const vuetify = createVuetify({ components, directives }); + +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); + }); +}); +``` + +- [ ] **Шаг 2: Запустить и убедиться, что падает** + +```bash +cd app && npx vitest run tests/Frontend/TenantActivityPane.spec.ts +``` + +Ожидаемо: `Failed to resolve import ".../TenantActivityPane.vue"`. + +- [ ] **Шаг 3: Написать компонент** + +Создать `app/resources/js/components/admin/tenant-detail/TenantActivityPane.vue`: + +```vue + + + + + +``` + +- [ ] **Шаг 4: Запустить тест** + +```bash +cd app && npx vitest run tests/Frontend/TenantActivityPane.spec.ts +``` + +Ожидаемо: зелено. + +- [ ] **Шаг 5: Коммит** + +```bash +git add app/resources/js/components/admin/tenant-detail/TenantActivityPane.vue app/tests/Frontend/TenantActivityPane.spec.ts +git commit -m "feat: панель «Активность» — вердикт, полоска 90 дней, лента дел" +``` + +--- + +## Задача 8: Вставить панель в карточку и подключить подгрузку + +**Files:** +- Modify: `app/resources/js/components/admin/tenant-detail/TenantDetailTabs.vue:129-142` (панель activity) и `:71-98` (колонка на вкладке «Пользователи») +- Modify: `app/resources/js/views/admin/AdminTenantDetailView.vue` +- Test: `app/tests/Frontend/AdminTenantDetailView.spec.ts` (дописываем) + +- [ ] **Шаг 1: Дописать падающий тест** + +В файле уже есть `makeApiDetail(overrides)` (строка 22) и `mountDetail(subdomain)` +(строка ~141) — пользуемся ими, своих не заводим. + +Сначала привести фикстуру `makeApiDetail` к новой форме ответа: заменить в ней +поле `activity: [...]` на + +```ts + 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, + }, +``` + +Затем дописать в конец `describe('AdminTenantDetailView.vue (API integration)', ...)`: + +```ts + it('на вкладке «Пользователи» колонка называется «Последний вход»', async () => { + (adminApi.getAdminTenantDetail as ReturnType).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).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'); + }); +``` + +- [ ] **Шаг 2: Запустить и убедиться, что падает** + +```bash +cd app && npx vitest run tests/Frontend/AdminTenantDetailView.spec.ts +``` + +Ожидаемо: падение — панель старая, колонка называется по-старому. + +- [ ] **Шаг 3: Заменить панель в `TenantDetailTabs.vue`** + +В ` + + diff --git a/landing-3d/public/env/studio-teal.jpg b/landing-3d/public/env/studio-teal.jpg new file mode 100644 index 00000000..8975a7f1 Binary files /dev/null and b/landing-3d/public/env/studio-teal.jpg differ diff --git a/landing-3d/public/env/studio-white-teal.jpg b/landing-3d/public/env/studio-white-teal.jpg new file mode 100644 index 00000000..b38fde08 Binary files /dev/null and b/landing-3d/public/env/studio-white-teal.jpg differ diff --git a/landing-3d/public/fonts/Unbounded-var.ttf b/landing-3d/public/fonts/Unbounded-var.ttf new file mode 100644 index 00000000..e3c064dd Binary files /dev/null and b/landing-3d/public/fonts/Unbounded-var.ttf differ diff --git a/landing-3d/public/fonts/unbounded.typeface.json b/landing-3d/public/fonts/unbounded.typeface.json new file mode 100644 index 00000000..7b4fc991 --- /dev/null +++ b/landing-3d/public/fonts/unbounded.typeface.json @@ -0,0 +1 @@ +{"glyphs":{"0":{"ha":893,"x_min":50,"x_max":843,"o":"m 447 -14 l 447 -14 q 324 -14 235 34 q 146 83 98 170 q 50 258 50 375 l 50 375 q 50 492 98 579 q 146 667 235 715 q 324 764 447 764 l 447 764 q 570 764 659 715 q 747 667 795 579 q 843 492 843 375 l 843 375 q 843 258 795 170 q 747 83 659 34 q 570 -14 447 -14 m 447 95 l 447 95 q 572 95 641 168 q 710 242 710 375 l 710 375 q 710 508 641 581 q 572 655 447 655 l 447 655 q 322 655 253 581 q 183 508 183 375 l 183 375 q 183 242 253 168 q 322 95 447 95"},"1":{"ha":471,"x_min":9,"x_max":383,"o":"m 284 750 l 383 750 l 383 0 l 251 0 l 251 710 l 323 646 l 9 591 l 9 696 l 284 750"},"2":{"ha":789,"x_min":42,"x_max":747,"o":"m 175 492 l 42 492 q 48 575 96 636 q 143 697 222 730 q 301 764 402 764 l 402 764 q 498 764 571 734 q 643 704 684 650 q 724 596 724 525 l 724 525 q 724 470 698 423 q 671 376 611 327 q 550 279 447 218 l 447 218 l 198 71 l 181 110 l 747 110 l 747 0 l 49 0 l 49 96 l 368 290 q 452 341 501 378 q 549 416 570 449 q 591 482 591 519 l 591 519 q 591 558 570 589 q 549 620 507 638 q 464 656 398 656 l 398 656 q 323 656 275 634 q 227 612 204 574 q 180 537 175 492 l 175 492"},"3":{"ha":804,"x_min":36,"x_max":752,"o":"m 190 519 l 57 519 q 64 588 108 643 q 151 699 230 731 q 308 764 417 764 l 417 764 q 514 764 586 738 q 657 713 696 667 q 735 622 735 560 l 735 560 q 735 508 705 466 q 674 425 612 401 q 550 377 457 377 l 457 377 l 457 396 q 549 400 615 379 q 681 359 717 317 q 752 276 752 216 l 752 216 q 752 149 711 97 q 670 45 593 15 q 515 -14 403 -14 l 403 -14 q 294 -14 213 17 q 132 49 87 106 q 41 163 36 239 l 36 239 l 169 239 q 178 169 237 131 q 296 94 403 94 l 403 94 q 510 94 564 129 q 618 164 618 225 l 618 225 q 618 261 600 285 q 581 310 539 323 q 496 336 424 336 l 424 336 l 288 336 l 288 434 l 424 434 q 517 434 559 464 q 601 495 601 543 l 601 543 q 601 596 555 626 q 508 656 410 656 l 410 656 q 314 656 258 620 q 201 585 190 519 l 190 519"},"4":{"ha":803,"x_min":23,"x_max":775,"o":"m 470 750 l 638 750 l 638 0 l 506 0 l 506 695 l 560 695 l 130 217 l 127 264 l 775 264 l 775 158 l 23 158 l 23 253 l 470 750"},"5":{"ha":775,"x_min":34,"x_max":734,"o":"m 68 305 l 126 750 l 674 750 l 674 643 l 169 643 l 224 678 l 182 362 l 143 361 q 166 396 206 427 q 245 458 302 477 q 358 497 429 497 l 429 497 q 527 497 595 463 q 663 430 699 373 q 734 316 734 244 l 734 244 q 734 169 692 111 q 650 53 574 19 q 497 -14 392 -14 l 392 -14 q 287 -14 210 17 q 133 48 89 101 q 44 155 34 221 l 34 221 l 167 221 q 181 162 239 128 q 297 94 392 94 l 392 94 q 456 94 503 112 q 549 131 575 164 q 601 198 601 244 l 601 244 q 601 289 580 322 q 558 356 515 374 q 471 393 404 393 l 404 393 q 349 393 306 380 q 262 367 231 347 q 199 327 178 305 l 178 305 l 68 305"},"6":{"ha":825,"x_min":51,"x_max":778,"o":"m 434 764 l 434 764 q 523 764 592 737 q 661 710 705 663 q 749 617 760 557 l 760 557 l 630 557 q 614 598 570 626 q 526 655 437 655 l 437 655 q 347 655 290 621 q 233 588 207 526 q 180 465 180 379 l 180 379 q 180 290 210 226 q 240 162 299 128 q 358 95 445 95 l 445 95 q 511 95 556 113 q 600 132 623 165 q 645 198 645 239 l 645 239 q 645 278 625 308 q 605 339 564 357 q 522 375 457 375 l 457 375 q 370 375 296 333 q 222 291 173 224 l 173 224 l 117 265 q 150 326 203 375 q 255 425 326 454 q 396 484 483 484 l 483 484 q 572 484 639 454 q 705 425 742 371 q 778 317 778 243 l 778 243 q 778 167 737 109 q 696 51 622 18 q 547 -14 445 -14 l 445 -14 q 316 -14 228 35 q 140 85 96 173 q 51 261 51 375 l 51 375 q 51 489 95 577 q 139 665 225 714 q 310 764 434 764"},"7":{"ha":704,"x_min":9,"x_max":676,"o":"m 283 0 l 142 0 l 560 679 l 559 640 l 9 640 l 9 750 l 676 750 l 676 643 l 283 0"},"8":{"ha":854,"x_min":49,"x_max":805,"o":"m 427 764 l 427 764 q 594 764 685 710 q 776 656 776 559 l 776 559 q 776 470 693 422 q 610 374 461 377 l 461 377 l 461 398 q 625 407 715 357 q 805 307 805 206 l 805 206 q 805 101 707 43 q 609 -14 427 -14 l 427 -14 q 246 -14 148 43 q 49 101 49 206 l 49 206 q 49 307 140 357 q 230 408 393 398 l 393 398 l 393 377 q 245 374 162 422 q 78 470 78 559 l 78 559 q 78 656 169 710 q 260 764 427 764 m 427 92 l 427 92 q 546 92 609 122 q 672 153 672 213 l 672 213 q 672 275 609 304 q 546 333 427 333 l 427 333 q 308 333 245 304 q 182 275 182 213 l 182 213 q 182 153 245 122 q 308 92 427 92 m 427 437 l 427 437 q 496 437 545 449 q 593 461 619 486 q 644 511 644 548 l 644 548 q 644 601 588 629 q 531 658 427 658 l 427 658 q 324 658 267 629 q 210 601 210 548 l 210 548 q 210 511 236 486 q 261 461 310 449 q 358 437 427 437"},"9":{"ha":825,"x_min":47,"x_max":774,"o":"m 391 -14 l 391 -14 q 303 -14 234 13 q 164 40 121 87 q 77 134 65 193 l 65 193 l 195 193 q 211 152 255 123 q 299 95 388 95 l 388 95 q 479 95 536 128 q 592 162 619 224 q 645 286 645 371 l 645 371 q 645 461 615 524 q 585 588 526 621 q 467 655 380 655 l 380 655 q 314 655 270 636 q 225 618 203 585 q 180 553 180 511 l 180 511 q 180 473 200 442 q 220 411 262 393 q 304 375 368 375 l 368 375 q 455 375 529 417 q 603 459 652 526 l 652 526 l 708 485 q 676 424 623 374 q 570 325 500 295 q 429 266 342 266 l 342 266 q 253 266 187 295 q 120 325 84 379 q 47 434 47 507 l 47 507 q 47 583 88 641 q 129 699 204 731 q 279 764 380 764 l 380 764 q 509 764 597 714 q 685 665 730 577 q 775 490 774 375 l 774 375 q 775 261 731 173 q 686 86 601 36 q 515 -14 391 -14"},"А":{"ha":899,"x_min":7,"x_max":892,"o":"m 708 172 l 184 172 l 184 281 l 708 281 l 708 172 m 367 750 l 532 750 l 892 0 l 751 0 l 424 700 l 475 700 l 148 0 l 7 0 l 367 750"},"Б":{"ha":809,"x_min":58,"x_max":776,"o":"m 164 348 l 164 450 l 514 450 q 598 450 657 421 q 715 393 746 342 q 776 292 776 224 l 776 224 q 776 157 744 106 q 712 56 653 28 q 594 0 514 0 l 514 0 l 58 0 l 117 396 l 58 750 l 714 750 l 714 640 l 141 640 l 191 707 l 243 398 l 189 40 l 138 106 l 502 106 q 567 106 606 138 q 644 171 644 227 l 644 227 q 644 283 605 315 q 566 348 501 348 l 501 348 l 164 348"},"В":{"ha":847,"x_min":58,"x_max":807,"o":"m 539 372 l 539 372 l 552 401 q 641 400 698 374 q 754 348 781 304 q 807 260 807 205 l 807 205 q 807 145 779 99 q 750 53 695 26 q 640 0 558 0 l 558 0 l 58 0 l 117 385 l 58 750 l 540 750 q 644 750 701 701 q 757 653 757 568 l 757 568 q 757 523 736 480 q 714 438 667 408 q 619 379 539 372 m 244 385 l 191 37 l 140 106 l 543 106 q 605 106 640 134 q 674 163 674 216 l 674 216 q 674 272 634 303 q 593 334 514 334 l 514 334 l 189 334 l 189 436 l 501 436 q 554 436 589 465 q 624 495 624 550 l 624 550 q 624 591 595 617 q 566 644 511 644 l 511 644 l 140 644 l 191 713 l 244 385"},"Г":{"ha":625,"x_min":84,"x_max":609,"o":"m 84 750 l 609 750 l 609 633 l 156 633 l 216 693 l 216 0 l 84 0 l 84 750"},"Д":{"ha":1010,"x_min":14,"x_max":985,"o":"m 137 -154 l 14 -154 l 14 116 l 132 116 l 210 114 l 768 114 l 799 116 l 985 116 l 985 -154 l 862 -154 l 862 0 l 137 0 l 137 -154 m 179 750 l 179 750 l 853 750 l 853 51 l 721 51 l 721 694 l 781 634 l 250 634 l 307 676 q 312 621 314 567 q 316 513 316 465 l 316 465 q 316 339 298 250 q 280 161 245 114 q 210 67 160 67 l 160 67 l 105 114 q 150 164 172 257 q 193 350 193 491 l 193 491 q 193 552 189 618 q 185 684 179 750"},"Е":{"ha":766,"x_min":58,"x_max":712,"o":"m 159 430 l 683 430 l 683 320 l 159 320 l 159 430 m 193 710 l 246 375 l 193 40 l 130 114 l 712 114 l 712 0 l 58 0 l 118 375 l 58 750 l 707 750 l 707 636 l 130 636 l 193 710"},"Ё":{"ha":766,"x_min":58,"x_max":712,"o":"m 159 430 l 683 430 l 683 320 l 159 320 l 159 430 m 193 710 l 246 375 l 193 40 l 130 114 l 712 114 l 712 0 l 58 0 l 118 375 l 58 750 l 707 750 l 707 636 l 130 636 l 193 710 m 239 825 l 239 825 q 203 825 181 845 q 158 865 158 897 l 158 897 q 158 929 181 948 q 203 968 239 968 l 239 968 q 275 968 298 948 q 320 929 320 897 l 320 897 q 320 865 298 845 q 275 825 239 825 m 534 825 l 534 825 q 498 825 476 845 q 453 865 453 897 l 453 897 q 453 929 476 948 q 498 968 534 968 l 534 968 q 570 968 593 948 q 615 929 615 897 l 615 897 q 615 865 593 845 q 570 825 534 825"},"Ж":{"ha":1367,"x_min":3,"x_max":1363,"o":"m 154 0 l 3 0 l 302 468 l 394 378 l 154 0 m 1363 0 l 1212 0 l 973 378 l 1065 468 l 1363 0 m 748 750 l 748 124 l 710 138 l 1209 750 l 1352 750 l 731 0 l 636 0 l 15 750 l 157 750 l 656 138 l 618 124 l 618 750 l 748 750"},"З":{"ha":810,"x_min":30,"x_max":763,"o":"m 406 -14 l 406 -14 q 295 -14 212 18 q 129 50 82 109 q 35 168 30 247 l 30 247 l 162 247 q 172 172 235 131 q 297 91 406 91 l 406 91 q 511 91 570 123 q 628 156 628 221 l 628 221 q 628 276 580 303 q 532 330 426 330 l 426 330 l 276 330 l 276 423 l 423 423 q 486 423 527 438 q 568 453 588 479 q 608 506 608 538 l 608 538 q 608 577 584 604 q 559 631 514 645 q 469 659 409 659 l 409 659 q 309 659 249 620 q 189 581 178 512 l 178 512 l 47 512 q 54 583 99 640 q 143 697 224 730 q 304 764 415 764 l 415 764 q 515 764 589 737 q 662 711 703 664 q 743 618 743 557 l 743 557 q 743 505 712 462 q 681 419 619 394 q 557 369 463 369 l 463 369 l 463 388 q 556 392 623 372 q 690 353 727 313 q 763 273 763 213 l 763 213 q 763 145 722 94 q 680 43 600 14 q 520 -14 406 -14"},"И":{"ha":946,"x_min":84,"x_max":862,"o":"m 252 0 l 84 0 l 84 750 l 212 750 l 212 62 l 167 70 l 690 750 l 862 750 l 862 0 l 734 0 l 734 694 l 779 686 l 252 0"},"Й":{"ha":946,"x_min":84,"x_max":862,"o":"m 252 0 l 84 0 l 84 750 l 212 750 l 212 62 l 167 70 l 690 750 l 862 750 l 862 0 l 734 0 l 734 694 l 779 686 l 252 0 m 250 965 l 250 965 l 358 965 q 369 927 397 906 q 424 886 467 886 l 467 886 q 511 886 538 906 q 565 927 576 965 l 576 965 l 684 965 q 667 885 609 845 q 551 806 467 806 l 467 806 q 383 806 325 845 q 267 885 250 965"},"К":{"ha":832,"x_min":84,"x_max":832,"o":"m 202 0 l 84 0 l 84 750 l 216 750 l 216 111 l 176 136 l 678 750 l 821 750 l 202 0 m 681 0 l 441 377 l 534 468 l 832 0 l 681 0"},"Л":{"ha":972,"x_min":11,"x_max":888,"o":"m 210 750 l 210 750 l 888 750 l 888 0 l 756 0 l 756 693 l 816 633 l 284 633 l 340 678 q 348 596 352 523 q 356 450 356 386 l 356 386 q 356 246 332 158 q 308 70 258 28 q 207 -13 126 -13 l 126 -13 q 95 -13 65 -7 q 35 0 11 13 l 11 13 l 11 125 q 53 108 90 108 l 90 108 q 164 108 198 178 q 231 248 231 405 l 231 405 q 231 473 226 560 q 221 648 210 750"},"М":{"ha":1177,"x_min":84,"x_max":1093,"o":"m 891 750 l 1093 750 l 1093 0 l 967 0 l 967 710 l 1008 708 l 659 0 l 518 0 l 169 707 l 210 709 l 210 0 l 84 0 l 84 750 l 286 750 l 622 57 l 556 57 l 891 750"},"Н":{"ha":910,"x_min":84,"x_max":826,"o":"m 84 0 l 84 750 l 216 750 l 216 0 l 84 0 m 694 0 l 694 750 l 826 750 l 826 0 l 694 0 m 155 320 l 155 436 l 757 436 l 757 320 l 155 320"},"О":{"ha":948,"x_min":44,"x_max":904,"o":"m 474 -14 l 474 -14 q 346 -14 249 35 q 152 84 98 171 q 44 259 44 375 l 44 375 q 44 491 98 578 q 152 666 249 715 q 346 764 474 764 l 474 764 q 602 764 699 715 q 796 666 850 578 q 904 491 904 375 l 904 375 q 904 259 850 171 q 796 84 699 35 q 602 -14 474 -14 m 474 104 l 474 104 q 565 104 631 137 q 697 170 734 231 q 770 292 770 375 l 770 375 q 770 458 734 519 q 697 580 631 613 q 565 646 474 646 l 474 646 q 384 646 318 613 q 251 580 215 519 q 178 458 178 375 l 178 375 q 178 292 215 231 q 251 170 318 137 q 384 104 474 104"},"П":{"ha":906,"x_min":84,"x_max":822,"o":"m 84 750 l 822 750 l 822 0 l 691 0 l 691 693 l 751 633 l 156 633 l 216 693 l 216 0 l 84 0 l 84 750"},"Р":{"ha":779,"x_min":84,"x_max":755,"o":"m 84 750 l 481 750 q 565 750 627 720 q 688 690 722 636 q 755 583 755 513 l 755 513 q 755 443 722 389 q 688 336 627 306 q 565 276 481 276 l 481 276 l 144 276 l 144 390 l 476 390 q 544 390 583 423 q 621 456 621 513 l 621 513 q 621 570 583 603 q 544 636 476 636 l 476 636 l 157 636 l 216 702 l 216 0 l 84 0 l 84 750"},"С":{"ha":912,"x_min":45,"x_max":875,"o":"m 742 297 l 875 297 q 865 205 811 134 q 756 64 668 25 q 580 -14 467 -14 l 467 -14 q 341 -14 246 35 q 151 84 98 171 q 45 259 45 375 l 45 375 q 45 491 98 578 q 151 666 246 715 q 341 764 467 764 l 467 764 q 580 764 668 725 q 756 686 811 616 q 865 546 875 453 l 875 453 l 742 453 q 731 513 695 556 q 658 599 600 622 q 542 646 466 646 l 466 646 q 378 646 314 613 q 249 580 214 519 q 179 458 179 375 l 179 375 q 179 292 214 231 q 249 170 314 137 q 378 104 466 104 l 466 104 q 542 104 600 127 q 658 150 695 193 q 731 237 742 297 l 742 297"},"Т":{"ha":784,"x_min":16,"x_max":768,"o":"m 326 0 l 326 689 l 458 689 l 458 0 l 326 0 m 16 634 l 16 750 l 768 750 l 768 634 l 16 634"},"У":{"ha":848,"x_min":12,"x_max":841,"o":"m 402 0 l 258 0 l 381 210 l 383 210 l 697 750 l 841 750 l 402 0 m 472 215 l 384 116 l 12 750 l 158 750 l 472 215"},"Ф":{"ha":1238,"x_min":31,"x_max":1207,"o":"m 898 130 l 630 130 l 630 237 l 892 237 q 948 237 989 256 q 1029 276 1051 311 q 1073 347 1073 395 l 1073 395 q 1073 443 1051 478 q 1029 513 989 532 q 948 552 892 552 l 892 552 l 631 552 l 631 659 l 898 659 q 992 659 1061 625 q 1130 592 1169 533 q 1207 474 1207 395 l 1207 395 q 1207 316 1169 256 q 1130 197 1061 163 q 992 130 898 130 l 898 130 m 607 130 l 340 130 q 247 130 178 163 q 108 197 70 256 q 31 316 31 395 l 31 395 q 31 474 70 533 q 108 592 178 625 q 247 659 340 659 l 340 659 l 606 659 l 606 552 l 345 552 q 290 552 250 532 q 209 513 187 478 q 165 443 165 395 l 165 395 q 165 347 187 311 q 209 276 250 256 q 290 237 345 237 l 345 237 l 607 237 l 607 130 m 685 -18 l 553 -18 l 553 768 l 685 768 l 685 -18"},"Х":{"ha":793,"x_min":-6,"x_max":799,"o":"m 774 750 l 449 353 l 449 429 l 799 0 l 636 0 l 366 340 l 424 340 l 153 0 l -6 0 l 345 427 l 345 355 l 19 750 l 183 750 l 430 441 l 370 441 l 615 750 l 774 750"},"Ц":{"ha":971,"x_min":84,"x_max":946,"o":"m 683 750 l 814 750 l 814 77 l 774 117 l 946 117 l 946 -154 l 823 -154 l 823 19 l 883 0 l 84 0 l 84 750 l 216 750 l 216 77 l 176 117 l 723 117 l 683 77 l 683 750"},"Ч":{"ha":842,"x_min":44,"x_max":758,"o":"m 363 259 l 363 259 q 208 259 126 337 q 44 415 44 563 l 44 563 l 44 750 l 176 750 l 176 573 q 176 476 230 425 q 284 375 389 375 l 389 375 q 449 375 515 389 q 581 404 646 430 l 646 430 l 655 323 q 589 292 514 275 q 438 259 363 259 m 758 0 l 626 0 l 626 750 l 758 750 l 758 0"},"Ш":{"ha":1336,"x_min":84,"x_max":1252,"o":"m 691 117 l 1180 117 l 1120 57 l 1120 750 l 1252 750 l 1252 0 l 84 0 l 84 750 l 216 750 l 216 57 l 156 117 l 639 117 l 602 57 l 602 750 l 734 750 l 734 57 l 691 117"},"Щ":{"ha":1409,"x_min":84,"x_max":1384,"o":"m 1120 750 l 1252 750 l 1252 77 l 1202 117 l 1384 117 l 1384 -154 l 1260 -154 l 1260 13 l 1320 0 l 84 0 l 84 750 l 216 750 l 216 77 l 166 117 l 652 117 l 602 77 l 602 750 l 734 750 l 734 77 l 684 117 l 1170 117 l 1120 77 l 1120 750"},"Ъ":{"ha":986,"x_min":17,"x_max":961,"o":"m 351 368 l 351 475 l 684 475 q 769 475 831 445 q 893 415 927 361 q 961 308 961 237 l 961 237 q 961 167 927 113 q 893 60 831 30 q 769 0 684 0 l 684 0 l 295 0 l 295 693 l 355 634 l 17 634 l 17 750 l 427 750 l 427 47 l 367 107 l 678 107 q 747 107 787 142 q 827 178 827 237 l 827 237 q 827 297 787 332 q 747 368 678 368 l 678 368 l 351 368"},"Ы":{"ha":1052,"x_min":84,"x_max":968,"o":"m 477 0 l 84 0 l 84 750 l 216 750 l 216 48 l 157 107 l 471 107 q 540 107 580 142 q 620 178 620 237 l 620 237 q 620 297 580 332 q 540 368 471 368 l 471 368 l 144 368 l 144 475 l 477 475 q 562 475 624 445 q 686 415 720 361 q 754 308 754 237 l 754 237 q 754 167 720 113 q 686 60 624 30 q 562 0 477 0 l 477 0 m 836 0 l 836 750 l 968 750 l 968 0 l 836 0"},"Ь":{"ha":779,"x_min":84,"x_max":754,"o":"m 477 0 l 84 0 l 84 750 l 216 750 l 216 48 l 157 107 l 471 107 q 540 107 580 142 q 620 178 620 237 l 620 237 q 620 297 580 332 q 540 368 471 368 l 471 368 l 144 368 l 144 475 l 477 475 q 562 475 624 445 q 686 415 720 361 q 754 308 754 237 l 754 237 q 754 167 720 113 q 686 60 624 30 q 562 0 477 0 l 477 0"},"Э":{"ha":918,"x_min":35,"x_max":873,"o":"m 447 -14 l 447 -14 q 335 -14 246 24 q 157 63 102 131 q 47 200 35 289 l 35 289 l 167 289 q 178 231 215 188 q 252 145 311 121 q 370 98 447 98 l 447 98 q 538 98 604 132 q 670 166 706 228 q 742 290 742 375 l 742 375 q 742 460 706 522 q 670 584 604 618 q 538 652 447 652 l 447 652 q 371 652 312 628 q 253 605 216 562 q 179 520 167 461 l 167 461 l 35 461 q 47 551 102 619 q 157 687 246 725 q 335 764 447 764 l 447 764 q 575 764 671 715 q 766 666 820 578 q 873 491 873 375 l 873 375 q 873 259 820 171 q 766 84 671 35 q 575 -14 447 -14 m 802 422 l 802 328 l 319 328 l 319 422 l 802 422"},"Ю":{"ha":1238,"x_min":84,"x_max":1193,"o":"m 768 -14 l 768 -14 q 641 -14 546 35 q 450 84 397 171 q 344 259 344 375 l 344 375 q 344 491 397 578 q 450 666 546 715 q 641 764 768 764 l 768 764 q 895 764 991 715 q 1086 666 1140 578 q 1193 491 1193 375 l 1193 375 q 1193 259 1140 171 q 1086 84 991 35 q 895 -14 768 -14 m 216 0 l 84 0 l 84 750 l 216 750 l 216 0 m 412 320 l 155 320 l 155 436 l 412 436 l 412 320 m 768 105 l 768 105 q 858 105 923 138 q 988 171 1024 232 q 1059 293 1059 375 l 1059 375 q 1059 458 1024 518 q 988 579 923 612 q 858 646 768 646 l 768 646 q 679 646 614 612 q 548 579 513 518 q 477 458 477 375 l 477 375 q 477 293 513 232 q 548 171 614 138 q 679 105 768 105"},"Я":{"ha":822,"x_min":35,"x_max":738,"o":"m 331 398 l 647 398 l 647 300 l 317 300 l 316 302 q 237 302 178 330 q 118 358 85 408 q 51 458 51 525 l 51 525 q 51 592 85 642 q 118 693 178 721 q 237 750 316 750 l 316 750 l 738 750 l 738 0 l 606 0 l 606 710 l 665 651 l 331 651 q 262 651 223 617 q 183 583 183 525 l 183 525 q 183 467 223 432 q 262 398 331 398 l 331 398 m 328 351 l 481 351 l 192 0 l 35 0 l 328 351"},"а":{"ha":787,"x_min":39,"x_max":731,"o":"m 731 0 l 597 0 l 575 210 l 601 283 l 575 355 l 597 566 l 731 566 l 701 283 l 731 0 m 637 283 l 637 283 q 618 194 574 127 q 530 60 466 23 q 401 -14 322 -14 l 322 -14 q 239 -14 175 23 q 111 61 75 128 q 39 195 39 283 l 39 283 q 39 372 75 438 q 111 505 175 542 q 239 580 322 580 l 322 580 q 401 580 466 543 q 530 506 575 439 q 619 373 637 283 m 171 283 l 171 283 q 171 226 195 182 q 218 139 259 114 q 300 90 353 90 l 353 90 q 407 90 453 114 q 499 139 535 182 q 570 226 591 283 l 591 283 q 570 340 535 383 q 499 427 453 451 q 407 476 353 476 l 353 476 q 300 476 259 451 q 218 427 195 383 q 171 340 171 283"},"б":{"ha":769,"x_min":46,"x_max":730,"o":"m 405 -14 l 405 -14 q 292 -14 212 30 q 131 75 89 159 q 46 243 46 361 l 46 361 q 46 537 138 639 q 230 741 401 755 l 401 755 q 470 761 516 765 q 561 769 590 773 q 619 778 640 783 q 660 788 679 795 l 679 795 l 679 684 q 660 676 628 669 q 596 663 547 659 q 498 655 427 651 l 427 651 q 293 644 225 571 q 157 498 157 363 l 157 363 l 157 331 l 150 339 q 172 398 214 439 q 255 480 313 502 q 371 524 440 524 l 440 524 q 527 524 592 491 q 657 458 694 398 q 730 339 730 260 l 730 260 q 730 177 690 115 q 649 54 576 20 q 503 -14 405 -14 m 408 88 l 408 88 q 466 88 509 109 q 551 130 575 168 q 598 207 598 258 l 598 258 q 598 309 575 347 q 551 385 509 406 q 466 427 408 427 l 408 427 q 330 427 271 385 q 211 343 182 266 l 182 266 q 199 184 260 136 q 320 88 408 88"},"в":{"ha":713,"x_min":56,"x_max":677,"o":"m 479 0 l 56 0 l 85 283 l 56 566 l 466 566 q 548 566 595 528 q 641 490 641 423 l 641 423 q 641 361 595 323 q 548 286 465 279 l 465 279 l 475 303 q 573 301 625 263 q 677 225 677 156 l 677 156 q 677 83 625 41 q 572 0 479 0 l 479 0 m 210 283 l 182 32 l 132 90 l 446 90 q 499 90 523 110 q 546 131 546 168 l 546 168 q 546 201 520 223 q 493 245 431 245 l 431 245 l 160 245 l 160 329 l 412 329 q 463 329 487 349 q 510 370 510 403 l 510 403 q 510 435 490 455 q 469 476 421 476 l 421 476 l 133 476 l 182 533 l 210 283"},"г":{"ha":529,"x_min":69,"x_max":516,"o":"m 69 566 l 516 566 l 516 464 l 139 464 l 199 524 l 199 0 l 69 0 l 69 566"},"д":{"ha":827,"x_min":10,"x_max":802,"o":"m 637 100 l 802 100 l 802 -143 l 689 -143 l 689 0 l 123 0 l 123 -143 l 10 -143 l 10 100 l 156 100 l 192 98 l 605 98 l 637 100 m 136 566 l 136 566 l 697 566 l 697 38 l 567 38 l 567 526 l 627 466 l 204 466 l 260 510 q 265 466 267 425 q 268 385 268 350 l 268 350 q 268 204 229 121 q 189 38 118 38 l 118 38 l 65 83 q 147 143 147 375 l 147 375 q 147 420 144 468 q 141 517 136 566"},"е":{"ha":710,"x_min":37,"x_max":672,"o":"m 378 -14 l 378 -14 q 279 -14 202 23 q 125 61 81 128 q 37 195 37 284 l 37 284 q 37 372 79 438 q 120 505 194 542 q 267 580 362 580 l 362 580 q 457 580 527 537 q 596 495 634 418 q 672 341 672 237 l 672 237 l 139 237 l 139 336 l 607 336 l 542 299 q 539 355 516 395 q 492 435 453 456 q 413 478 359 478 l 359 478 q 302 478 260 455 q 217 432 193 390 q 169 349 169 292 l 169 292 q 169 229 198 183 q 226 137 279 112 q 331 88 403 88 l 403 88 q 468 88 536 108 q 604 129 656 166 l 656 166 l 656 72 q 604 32 530 9 q 455 -14 378 -14"},"ё":{"ha":710,"x_min":37,"x_max":672,"o":"m 378 -14 l 378 -14 q 279 -14 202 23 q 125 61 81 128 q 37 195 37 284 l 37 284 q 37 372 79 438 q 120 505 194 542 q 267 580 362 580 l 362 580 q 457 580 527 537 q 596 495 634 418 q 672 341 672 237 l 672 237 l 139 237 l 139 336 l 607 336 l 542 299 q 539 355 516 395 q 492 435 453 456 q 413 478 359 478 l 359 478 q 302 478 260 455 q 217 432 193 390 q 169 349 169 292 l 169 292 q 169 229 198 183 q 226 137 279 112 q 331 88 403 88 l 403 88 q 468 88 536 108 q 604 129 656 166 l 656 166 l 656 72 q 604 32 530 9 q 455 -14 378 -14 m 213 641 l 213 641 q 177 641 155 660 q 132 680 132 713 l 132 713 q 132 745 155 764 q 177 784 213 784 l 213 784 q 249 784 272 764 q 294 745 294 713 l 294 713 q 294 680 272 660 q 249 641 213 641 m 506 641 l 506 641 q 470 641 448 660 q 425 680 425 713 l 425 713 q 425 745 448 764 q 470 784 506 784 l 506 784 q 541 784 564 764 q 586 745 586 713 l 586 713 q 586 680 564 660 q 541 641 506 641"},"ж":{"ha":1100,"x_min":-1,"x_max":1101,"o":"m 151 0 l -1 0 l 236 362 l 327 280 l 151 0 m 1101 0 l 949 0 l 774 280 l 864 362 l 1101 0 m 614 566 l 614 112 l 576 126 l 935 566 l 1083 566 l 602 0 l 498 0 l 17 566 l 165 566 l 524 126 l 486 112 l 486 566 l 614 566"},"з":{"ha":667,"x_min":27,"x_max":628,"o":"m 333 -14 l 333 -14 q 243 -14 176 10 q 108 34 70 79 q 31 124 27 186 l 27 186 l 153 186 q 161 137 208 111 q 254 86 333 86 l 333 86 q 409 86 452 106 q 495 127 495 170 l 495 170 q 495 205 460 222 q 424 239 347 239 l 347 239 l 235 239 l 235 329 l 344 329 q 413 329 447 350 q 480 371 480 402 l 480 402 q 480 440 440 460 q 400 480 335 480 l 335 480 q 263 480 218 457 q 173 434 165 388 l 165 388 l 39 388 q 44 442 81 485 q 117 529 183 554 q 249 580 340 580 l 340 580 q 423 580 484 559 q 545 538 579 501 q 612 465 612 418 l 612 418 q 612 380 589 348 q 565 316 516 297 q 466 278 388 278 l 388 278 l 388 295 q 502 299 565 264 q 628 230 628 162 l 628 162 q 628 111 594 71 q 559 31 493 8 q 427 -14 333 -14"},"и":{"ha":782,"x_min":69,"x_max":713,"o":"m 233 0 l 69 0 l 69 566 l 197 566 l 197 65 l 158 74 l 545 566 l 713 566 l 713 0 l 585 0 l 585 508 l 625 498 l 233 0"},"й":{"ha":782,"x_min":69,"x_max":713,"o":"m 233 0 l 69 0 l 69 566 l 197 566 l 197 65 l 158 74 l 545 566 l 713 566 l 713 0 l 585 0 l 585 508 l 625 498 l 233 0 m 388 623 l 388 623 q 298 623 244 668 q 189 713 176 798 l 176 798 l 281 798 q 289 752 317 727 q 345 703 388 703 l 388 703 q 431 703 459 727 q 487 752 495 798 l 495 798 l 599 798 q 587 713 532 668 q 477 623 388 623"},"к":{"ha":683,"x_min":69,"x_max":687,"o":"m 189 0 l 69 0 l 69 566 l 199 566 l 199 110 l 160 124 l 521 566 l 669 566 l 189 0 m 687 0 l 535 0 l 359 280 l 450 362 l 687 0"},"л":{"ha":794,"x_min":11,"x_max":725,"o":"m 168 566 l 168 566 l 725 566 l 725 0 l 595 0 l 595 526 l 655 466 l 233 466 l 289 512 q 294 450 298 394 q 301 339 301 295 l 301 295 q 301 135 256 62 q 211 -11 111 -11 l 111 -11 q 84 -11 59 -5 q 34 1 11 14 l 11 14 l 11 111 q 29 104 47 100 q 64 96 79 96 l 79 96 q 134 96 159 147 q 183 198 183 313 l 183 313 q 183 363 180 427 q 176 491 168 566"},"м":{"ha":947,"x_min":69,"x_max":878,"o":"m 191 0 l 69 0 l 69 566 l 262 566 l 500 62 l 449 62 l 685 566 l 878 566 l 878 0 l 756 0 l 756 523 l 790 523 l 537 0 l 410 0 l 157 521 l 191 522 l 191 0"},"н":{"ha":748,"x_min":69,"x_max":679,"o":"m 69 0 l 69 566 l 198 566 l 198 0 l 69 0 m 550 0 l 550 566 l 679 566 l 679 0 l 550 0 m 142 237 l 142 337 l 604 337 l 604 237 l 142 237"},"о":{"ha":764,"x_min":37,"x_max":727,"o":"m 382 -14 l 382 -14 q 279 -14 202 23 q 124 61 81 128 q 37 195 37 283 l 37 283 q 37 372 81 438 q 124 505 202 542 q 279 580 382 580 l 382 580 q 485 580 563 542 q 640 505 684 438 q 727 372 727 283 l 727 283 q 727 195 684 128 q 640 61 563 23 q 485 -14 382 -14 m 382 88 l 382 88 q 448 88 496 112 q 543 136 569 179 q 595 223 595 283 l 595 283 q 595 343 569 386 q 543 430 496 454 q 448 478 382 478 l 382 478 q 317 478 269 454 q 221 430 195 386 q 169 343 169 283 l 169 283 q 169 223 195 179 q 221 136 269 112 q 317 88 382 88"},"п":{"ha":744,"x_min":69,"x_max":675,"o":"m 69 566 l 675 566 l 675 0 l 545 0 l 545 525 l 605 466 l 139 466 l 199 525 l 199 0 l 69 0 l 69 566"},"р":{"ha":786,"x_min":69,"x_max":747,"o":"m 69 -165 l 69 566 l 199 566 l 199 369 l 193 349 l 193 235 l 199 200 l 199 -165 l 69 -165 m 149 283 l 149 283 q 168 373 212 439 q 256 506 321 543 q 385 580 464 580 l 464 580 q 547 580 611 542 q 675 505 711 438 q 747 372 747 283 l 747 283 q 747 195 711 128 q 675 61 611 23 q 547 -14 464 -14 l 464 -14 q 385 -14 321 23 q 257 60 213 127 q 168 194 149 283 m 615 283 l 615 283 q 615 340 592 383 q 568 427 527 451 q 486 476 433 476 l 433 476 q 380 476 334 451 q 287 427 252 383 q 217 340 195 283 l 195 283 q 217 226 252 182 q 287 139 334 114 q 380 90 433 90 l 433 90 q 486 90 527 114 q 568 139 592 182 q 615 226 615 283"},"с":{"ha":724,"x_min":37,"x_max":694,"o":"m 563 223 l 694 223 q 684 153 639 99 q 594 46 524 16 q 454 -14 368 -14 l 368 -14 q 270 -14 196 23 q 121 61 79 128 q 37 195 37 283 l 37 283 q 37 371 79 438 q 121 505 196 542 q 270 580 368 580 l 368 580 q 454 580 524 550 q 594 520 639 466 q 684 413 694 342 l 694 342 l 563 342 q 549 406 496 442 q 443 478 368 478 l 368 478 q 308 478 263 454 q 218 431 194 387 q 169 344 169 283 l 169 283 q 169 223 194 179 q 218 135 263 111 q 308 88 368 88 l 368 88 q 444 88 497 125 q 550 162 563 223 l 563 223"},"т":{"ha":626,"x_min":13,"x_max":613,"o":"m 248 0 l 248 521 l 378 521 l 378 0 l 248 0 m 13 464 l 13 566 l 613 566 l 613 464 l 13 464"},"у":{"ha":715,"x_min":10,"x_max":708,"o":"m 205 -179 l 205 -179 q 155 -179 115 -167 q 74 -154 39 -132 l 39 -132 l 39 -33 q 77 -57 112 -68 q 146 -79 189 -79 l 189 -79 q 230 -79 262 -61 q 294 -42 315 4 l 315 4 l 570 566 l 708 566 l 415 -40 q 391 -90 359 -121 q 326 -151 288 -165 q 249 -179 205 -179 m 399 18 l 270 18 l 10 566 l 153 566 l 399 18"},"ф":{"ha":1269,"x_min":39,"x_max":1230,"o":"m 698 -165 l 570 -165 l 570 171 l 582 217 l 582 355 l 570 402 l 570 770 l 698 770 l 698 401 l 686 355 l 686 218 l 698 171 l 698 -165 m 648 283 l 648 283 q 667 373 709 439 q 751 506 814 543 q 876 580 954 580 l 954 580 q 1036 580 1098 542 q 1160 505 1195 438 q 1230 372 1230 283 l 1230 283 q 1230 195 1195 128 q 1160 61 1098 23 q 1036 -14 954 -14 l 954 -14 q 876 -14 814 23 q 751 60 709 127 q 667 194 648 283 m 1098 283 l 1098 283 q 1098 340 1076 383 q 1053 427 1014 451 q 975 476 923 476 l 923 476 q 844 476 784 422 q 724 369 692 283 l 692 283 q 724 198 784 143 q 844 89 923 89 l 923 89 q 975 89 1014 114 q 1053 139 1076 182 q 1098 226 1098 283 m 620 283 l 620 283 q 602 194 560 127 q 517 60 455 23 q 393 -14 315 -14 l 315 -14 q 233 -14 171 23 q 109 61 74 128 q 39 195 39 283 l 39 283 q 39 372 74 438 q 109 505 171 542 q 233 580 315 580 l 315 580 q 393 580 455 543 q 517 506 560 439 q 602 373 620 283 m 171 283 l 171 283 q 171 226 193 182 q 215 139 255 114 q 294 89 345 89 l 345 89 q 425 89 485 143 q 544 198 577 283 l 577 283 q 544 369 485 422 q 425 476 345 476 l 345 476 q 294 476 255 451 q 215 427 193 383 q 171 340 171 283"},"х":{"ha":646,"x_min":-6,"x_max":653,"o":"m 139 0 l -6 0 l 267 323 l 266 259 l 10 566 l 159 566 l 353 336 l 298 336 l 493 566 l 637 566 l 376 257 l 371 332 l 653 0 l 504 0 l 292 248 l 349 248 l 139 0"},"ц":{"ha":797,"x_min":69,"x_max":772,"o":"m 537 566 l 667 566 l 667 72 l 645 100 l 772 100 l 772 -143 l 659 -143 l 659 23 l 681 0 l 69 0 l 69 566 l 199 566 l 199 72 l 178 100 l 556 100 l 537 72 l 537 566"},"ч":{"ha":693,"x_min":36,"x_max":624,"o":"m 274 175 l 274 175 q 158 175 97 238 q 36 302 36 424 l 36 424 l 36 566 l 165 566 l 165 429 q 165 279 306 279 l 306 279 q 364 279 416 306 q 467 333 510 387 l 510 387 l 518 295 q 483 238 419 206 q 354 175 274 175 m 624 0 l 494 0 l 494 566 l 624 566 l 624 0"},"ш":{"ha":1124,"x_min":69,"x_max":1055,"o":"m 1055 0 l 69 0 l 69 566 l 199 566 l 199 61 l 159 100 l 519 100 l 497 61 l 497 566 l 627 566 l 627 61 l 595 100 l 965 100 l 925 61 l 925 566 l 1055 566 l 1055 0"},"щ":{"ha":1183,"x_min":69,"x_max":1155,"o":"m 925 566 l 1055 566 l 1055 60 l 1015 100 l 1155 100 l 1155 -143 l 1042 -143 l 1042 32 l 1064 0 l 69 0 l 69 566 l 199 566 l 199 60 l 159 100 l 537 100 l 497 60 l 497 566 l 627 566 l 627 60 l 587 100 l 965 100 l 925 60 l 925 566"},"ъ":{"ha":798,"x_min":14,"x_max":776,"o":"m 311 295 l 311 388 l 548 388 q 617 388 668 363 q 719 339 748 295 q 776 252 776 194 l 776 194 q 776 136 748 92 q 719 48 669 24 q 618 0 550 0 l 550 0 l 209 0 l 209 524 l 269 464 l 14 464 l 14 566 l 339 566 l 339 36 l 278 96 l 530 96 q 583 96 614 122 q 644 149 644 195 l 644 195 q 644 242 614 268 q 583 295 530 295 l 530 295 l 311 295"},"ы":{"ha":904,"x_min":69,"x_max":835,"o":"m 705 0 l 705 566 l 835 566 l 835 0 l 705 0 m 413 0 l 69 0 l 69 566 l 199 566 l 199 36 l 153 96 l 395 96 q 448 96 478 122 q 508 149 508 195 l 508 195 q 508 242 478 268 q 448 295 395 295 l 395 295 l 157 295 l 157 388 l 412 388 q 481 388 532 363 q 583 339 612 295 q 640 252 640 194 l 640 194 q 640 136 612 92 q 583 48 532 24 q 481 0 413 0 l 413 0"},"ь":{"ha":662,"x_min":69,"x_max":640,"o":"m 413 0 l 69 0 l 69 566 l 199 566 l 199 36 l 153 96 l 395 96 q 448 96 478 122 q 508 149 508 195 l 508 195 q 508 242 478 268 q 448 295 395 295 l 395 295 l 157 295 l 157 388 l 412 388 q 481 388 532 363 q 583 339 612 295 q 640 252 640 194 l 640 194 q 640 136 612 92 q 583 48 532 24 q 481 0 413 0 l 413 0"},"э":{"ha":726,"x_min":29,"x_max":689,"o":"m 356 -14 l 356 -14 q 271 -14 202 15 q 132 44 87 95 q 42 146 29 213 l 29 213 l 161 213 q 177 155 230 119 q 283 84 356 84 l 356 84 q 418 84 464 108 q 510 132 535 177 q 560 222 560 283 l 560 283 q 560 345 535 389 q 510 434 464 458 q 418 482 356 482 l 356 482 q 283 482 230 446 q 177 410 161 354 l 161 354 l 29 354 q 42 421 87 471 q 132 522 202 551 q 271 580 356 580 l 356 580 q 455 580 530 542 q 605 505 647 438 q 689 371 689 283 l 689 283 q 689 195 647 128 q 605 61 530 23 q 455 -14 356 -14 m 621 330 l 621 236 l 266 236 l 266 330 l 621 330"},"ю":{"ha":1029,"x_min":69,"x_max":992,"o":"m 651 -14 l 651 -14 q 550 -14 473 23 q 396 61 354 128 q 311 195 311 283 l 311 283 q 311 372 354 438 q 396 505 473 542 q 550 580 651 580 l 651 580 q 753 580 830 542 q 906 505 949 438 q 992 372 992 283 l 992 283 q 992 195 949 128 q 906 61 830 23 q 753 -14 651 -14 m 199 0 l 69 0 l 69 566 l 199 566 l 199 0 m 391 237 l 141 237 l 141 337 l 391 337 l 391 237 m 651 88 l 651 88 q 716 88 763 112 q 809 136 835 180 q 860 224 860 283 l 860 283 q 860 343 835 386 q 809 430 763 454 q 716 478 651 478 l 651 478 q 587 478 540 454 q 493 430 468 386 q 442 343 442 283 l 442 283 q 442 224 468 180 q 493 136 540 112 q 587 88 651 88"},"я":{"ha":701,"x_min":32,"x_max":632,"o":"m 280 299 l 540 299 l 540 208 l 254 208 l 252 211 q 190 211 143 233 q 96 255 70 295 q 44 335 44 388 l 44 388 q 44 441 70 481 q 96 521 143 543 q 190 566 252 566 l 252 566 l 632 566 l 632 0 l 502 0 l 502 529 l 545 473 l 280 473 q 231 473 204 450 q 176 427 176 387 l 176 387 q 176 347 204 323 q 231 299 280 299 l 280 299 m 248 265 l 393 265 l 183 0 l 32 0 l 248 265"},"A":{"ha":899,"x_min":7,"x_max":892,"o":"m 708 172 l 184 172 l 184 281 l 708 281 l 708 172 m 367 750 l 532 750 l 892 0 l 751 0 l 424 700 l 475 700 l 148 0 l 7 0 l 367 750"},"B":{"ha":847,"x_min":58,"x_max":807,"o":"m 539 372 l 539 372 l 552 401 q 641 400 698 374 q 754 348 781 304 q 807 260 807 205 l 807 205 q 807 145 779 99 q 750 53 695 26 q 640 0 558 0 l 558 0 l 58 0 l 117 385 l 58 750 l 540 750 q 644 750 701 701 q 757 653 757 568 l 757 568 q 757 523 736 480 q 714 438 667 408 q 619 379 539 372 m 244 385 l 191 37 l 140 106 l 543 106 q 605 106 640 134 q 674 163 674 216 l 674 216 q 674 272 634 303 q 593 334 514 334 l 514 334 l 189 334 l 189 436 l 501 436 q 554 436 589 465 q 624 495 624 550 l 624 550 q 624 591 595 617 q 566 644 511 644 l 511 644 l 140 644 l 191 713 l 244 385"},"C":{"ha":912,"x_min":45,"x_max":875,"o":"m 742 297 l 875 297 q 865 205 811 134 q 756 64 668 25 q 580 -14 467 -14 l 467 -14 q 341 -14 246 35 q 151 84 98 171 q 45 259 45 375 l 45 375 q 45 491 98 578 q 151 666 246 715 q 341 764 467 764 l 467 764 q 580 764 668 725 q 756 686 811 616 q 865 546 875 453 l 875 453 l 742 453 q 731 513 695 556 q 658 599 600 622 q 542 646 466 646 l 466 646 q 378 646 314 613 q 249 580 214 519 q 179 458 179 375 l 179 375 q 179 292 214 231 q 249 170 314 137 q 378 104 466 104 l 466 104 q 542 104 600 127 q 658 150 695 193 q 731 237 742 297 l 742 297"},"D":{"ha":896,"x_min":84,"x_max":852,"o":"m 84 750 l 423 750 q 551 750 648 703 q 744 656 798 571 q 852 487 852 375 l 852 375 q 852 263 798 178 q 744 94 648 47 q 551 0 423 0 l 423 0 l 84 0 l 84 750 m 216 704 l 216 46 l 143 116 l 432 116 q 518 116 583 148 q 647 181 683 239 q 718 298 718 375 l 718 375 q 718 453 683 511 q 647 569 583 601 q 518 634 432 634 l 432 634 l 143 634 l 216 704"},"E":{"ha":766,"x_min":58,"x_max":712,"o":"m 159 430 l 683 430 l 683 320 l 159 320 l 159 430 m 193 710 l 246 375 l 193 40 l 130 114 l 712 114 l 712 0 l 58 0 l 118 375 l 58 750 l 707 750 l 707 636 l 130 636 l 193 710"},"F":{"ha":740,"x_min":84,"x_max":703,"o":"m 148 306 l 148 422 l 687 422 l 687 306 l 148 306 m 84 750 l 703 750 l 703 634 l 144 634 l 216 709 l 216 0 l 84 0 l 84 750"},"G":{"ha":946,"x_min":45,"x_max":882,"o":"m 776 337 l 835 326 q 834 256 808 194 q 782 133 734 86 q 686 39 618 12 q 549 -14 461 -14 l 461 -14 q 372 -14 296 13 q 220 40 164 90 q 107 141 76 213 q 45 285 45 375 l 45 375 q 45 465 77 537 q 108 609 165 659 q 222 710 299 737 q 376 764 468 764 l 468 764 q 583 764 670 726 q 756 689 809 624 q 862 559 874 475 l 874 475 l 740 475 q 729 528 693 567 q 656 606 599 627 q 542 648 467 648 l 467 648 q 379 648 315 615 q 250 583 215 522 q 179 461 179 375 l 179 375 q 179 289 216 227 q 252 165 319 132 q 386 99 477 99 l 477 99 q 565 99 631 129 q 697 159 735 212 q 773 266 776 337 l 776 337 m 799 269 l 503 269 l 503 369 l 882 369 l 882 0 l 784 0 l 768 311 l 799 269"},"H":{"ha":910,"x_min":84,"x_max":826,"o":"m 84 0 l 84 750 l 216 750 l 216 0 l 84 0 m 694 0 l 694 750 l 826 750 l 826 0 l 694 0 m 155 320 l 155 436 l 757 436 l 757 320 l 155 320"},"I":{"ha":300,"x_min":84,"x_max":216,"o":"m 84 0 l 84 750 l 216 750 l 216 0 l 84 0"},"J":{"ha":779,"x_min":25,"x_max":704,"o":"m 25 318 l 25 359 l 155 359 l 155 326 q 155 221 204 162 q 253 104 363 104 l 363 104 q 473 104 523 161 q 572 218 572 318 l 572 318 l 572 750 l 704 750 l 704 313 q 704 205 661 132 q 618 59 541 22 q 464 -14 363 -14 l 363 -14 q 263 -14 187 23 q 110 60 68 134 q 25 208 25 318 l 25 318"},"K":{"ha":832,"x_min":84,"x_max":832,"o":"m 202 0 l 84 0 l 84 750 l 216 750 l 216 111 l 176 136 l 678 750 l 821 750 l 202 0 m 681 0 l 441 377 l 534 468 l 832 0 l 681 0"},"L":{"ha":738,"x_min":84,"x_max":722,"o":"m 84 750 l 216 750 l 216 59 l 157 116 l 722 116 l 722 0 l 84 0 l 84 750"},"M":{"ha":1177,"x_min":84,"x_max":1093,"o":"m 891 750 l 1093 750 l 1093 0 l 967 0 l 967 710 l 1008 708 l 659 0 l 518 0 l 169 707 l 210 709 l 210 0 l 84 0 l 84 750 l 286 750 l 622 57 l 556 57 l 891 750"},"N":{"ha":937,"x_min":84,"x_max":853,"o":"m 256 750 l 779 80 l 725 74 l 725 750 l 853 750 l 853 0 l 685 0 l 159 676 l 212 682 l 212 0 l 84 0 l 84 750 l 256 750"},"O":{"ha":948,"x_min":44,"x_max":904,"o":"m 474 -14 l 474 -14 q 346 -14 249 35 q 152 84 98 171 q 44 259 44 375 l 44 375 q 44 491 98 578 q 152 666 249 715 q 346 764 474 764 l 474 764 q 602 764 699 715 q 796 666 850 578 q 904 491 904 375 l 904 375 q 904 259 850 171 q 796 84 699 35 q 602 -14 474 -14 m 474 104 l 474 104 q 565 104 631 137 q 697 170 734 231 q 770 292 770 375 l 770 375 q 770 458 734 519 q 697 580 631 613 q 565 646 474 646 l 474 646 q 384 646 318 613 q 251 580 215 519 q 178 458 178 375 l 178 375 q 178 292 215 231 q 251 170 318 137 q 384 104 474 104"},"P":{"ha":779,"x_min":84,"x_max":755,"o":"m 84 750 l 481 750 q 565 750 627 720 q 688 690 722 636 q 755 583 755 513 l 755 513 q 755 443 722 389 q 688 336 627 306 q 565 276 481 276 l 481 276 l 144 276 l 144 390 l 476 390 q 544 390 583 423 q 621 456 621 513 l 621 513 q 621 570 583 603 q 544 636 476 636 l 476 636 l 157 636 l 216 702 l 216 0 l 84 0 l 84 750"},"Q":{"ha":948,"x_min":44,"x_max":904,"o":"m 709 -52 l 379 286 l 540 286 l 873 -52 l 709 -52 m 474 -14 l 474 -14 q 346 -14 249 35 q 152 84 98 171 q 44 259 44 375 l 44 375 q 44 491 98 578 q 152 666 249 715 q 346 764 474 764 l 474 764 q 602 764 699 715 q 796 666 850 578 q 904 491 904 375 l 904 375 q 904 259 850 171 q 796 84 699 35 q 602 -14 474 -14 m 474 104 l 474 104 q 565 104 631 137 q 697 170 734 231 q 770 292 770 375 l 770 375 q 770 458 734 519 q 697 580 631 613 q 565 646 474 646 l 474 646 q 384 646 318 613 q 251 580 215 519 q 178 458 178 375 l 178 375 q 178 292 215 231 q 251 170 318 137 q 384 104 474 104"},"R":{"ha":811,"x_min":84,"x_max":777,"o":"m 176 300 l 176 412 l 481 412 q 547 412 586 442 q 624 472 624 524 l 624 524 q 624 577 586 606 q 547 636 481 636 l 481 636 l 157 636 l 216 710 l 216 0 l 84 0 l 84 750 l 493 750 q 572 750 632 721 q 691 693 725 642 q 758 592 758 524 l 758 524 q 758 458 725 407 q 691 357 632 328 q 572 300 493 300 l 493 300 l 176 300 m 619 0 l 324 351 l 479 351 l 777 0 l 619 0"},"S":{"ha":803,"x_min":38,"x_max":764,"o":"m 38 244 l 38 244 l 172 244 q 178 199 210 165 q 241 131 293 112 q 345 94 413 94 l 413 94 q 514 94 573 125 q 632 157 632 211 l 632 211 q 632 255 598 277 q 564 299 475 310 l 475 310 l 313 331 q 177 349 116 398 q 54 448 54 539 l 54 539 q 54 608 96 658 q 137 709 213 736 q 289 764 391 764 l 391 764 q 492 764 570 734 q 648 704 695 650 q 741 597 745 525 l 745 525 l 611 525 q 606 565 578 594 q 549 623 501 639 q 453 656 388 656 l 388 656 q 296 656 241 626 q 186 596 186 544 l 186 544 q 186 504 219 482 q 251 461 332 450 l 332 450 l 496 427 q 597 413 656 389 q 714 366 739 326 q 764 287 764 225 l 764 225 q 764 153 720 99 q 676 45 597 15 q 517 -14 411 -14 l 411 -14 q 303 -14 221 18 q 138 51 90 109 q 42 167 38 244"},"T":{"ha":784,"x_min":16,"x_max":768,"o":"m 326 0 l 326 689 l 458 689 l 458 0 l 326 0 m 16 634 l 16 750 l 768 750 l 768 634 l 16 634"},"U":{"ha":862,"x_min":74,"x_max":788,"o":"m 206 750 l 206 322 q 206 255 233 206 q 260 157 311 130 q 361 104 431 104 l 431 104 q 502 104 552 130 q 602 157 629 206 q 656 255 656 322 l 656 322 l 656 750 l 788 750 l 788 317 q 788 218 743 143 q 698 69 618 27 q 538 -14 431 -14 l 431 -14 q 325 -14 245 27 q 164 69 119 143 q 74 218 74 317 l 74 317 l 74 750 l 206 750"},"V":{"ha":885,"x_min":7,"x_max":878,"o":"m 149 750 l 476 56 l 414 56 l 737 750 l 878 750 l 516 0 l 369 0 l 7 750 l 149 750"},"W":{"ha":1316,"x_min":14,"x_max":1302,"o":"m 730 750 l 984 50 l 934 50 l 1164 750 l 1302 750 l 1048 0 l 880 0 l 627 687 l 689 687 l 436 0 l 268 0 l 14 750 l 153 750 l 382 50 l 334 51 l 587 750 l 730 750"},"X":{"ha":793,"x_min":-6,"x_max":799,"o":"m 774 750 l 449 353 l 449 429 l 799 0 l 636 0 l 366 340 l 424 340 l 153 0 l -6 0 l 345 427 l 345 355 l 19 750 l 183 750 l 430 441 l 370 441 l 615 750 l 774 750"},"Y":{"ha":871,"x_min":16,"x_max":855,"o":"m 502 0 l 370 0 l 370 358 l 502 358 l 502 0 m 394 276 l 16 750 l 165 750 l 471 366 l 400 366 l 706 750 l 855 750 l 478 277 l 394 276"},"Z":{"ha":775,"x_min":40,"x_max":734,"o":"m 734 0 l 40 0 l 40 101 l 594 684 l 679 640 l 44 640 l 44 750 l 731 750 l 731 650 l 176 65 l 103 110 l 734 110 l 734 0"}," ":{"ha":265,"x_min":0,"x_max":0,"o":""},".":{"ha":258,"x_min":47,"x_max":211,"o":"m 129 -8 l 129 -8 q 106 -8 88 3 q 69 14 58 32 q 47 51 47 74 l 47 74 q 47 97 58 115 q 69 134 88 145 q 106 156 129 156 l 129 156 q 152 156 171 145 q 189 134 200 115 q 211 97 211 74 l 211 74 q 211 51 200 32 q 189 14 171 3 q 152 -8 129 -8"},",":{"ha":261,"x_min":40,"x_max":212,"o":"m 112 -138 l 40 -138 l 123 41 l 130 -6 q 107 -6 89 5 q 70 16 59 34 q 48 53 48 75 l 48 75 q 48 98 59 116 q 70 134 89 144 q 107 155 130 155 l 130 155 q 154 155 173 144 q 191 133 202 114 q 212 95 212 71 l 212 71 q 212 48 202 22 q 192 -3 167 -44 l 167 -44 l 112 -138"},":":{"ha":294,"x_min":65,"x_max":229,"o":"m 147 -8 l 147 -8 q 113 -8 89 16 q 65 40 65 74 l 65 74 q 65 109 89 132 q 113 156 147 156 l 147 156 q 182 156 206 132 q 229 109 229 74 l 229 74 q 229 40 206 16 q 182 -8 147 -8 m 147 360 l 147 360 q 113 360 89 384 q 65 408 65 442 l 65 442 q 65 477 89 500 q 113 524 147 524 l 147 524 q 182 524 206 500 q 229 477 229 442 l 229 442 q 229 408 206 384 q 182 360 147 360"},";":{"ha":301,"x_min":62,"x_max":233,"o":"m 151 360 l 151 360 q 117 360 93 384 q 69 408 69 442 l 69 442 q 69 477 93 500 q 117 524 151 524 l 151 524 q 186 524 210 500 q 233 477 233 442 l 233 442 q 233 408 210 384 q 186 360 151 360 m 133 -138 l 62 -138 l 145 41 l 152 -7 q 116 -7 94 14 q 71 36 71 70 l 71 70 q 71 103 94 124 q 116 146 152 146 l 152 146 q 188 146 211 124 q 233 102 233 67 l 233 67 q 233 46 224 21 q 214 -4 188 -45 l 188 -45 l 133 -138"},"!":{"ha":280,"x_min":59,"x_max":221,"o":"m 94 228 l 73 750 l 207 750 l 186 228 l 94 228 m 140 -8 l 140 -8 q 105 -8 82 13 q 59 35 59 69 l 59 69 q 59 103 82 124 q 105 146 140 146 l 140 146 q 176 146 199 124 q 221 103 221 69 l 221 69 q 221 35 199 13 q 176 -8 140 -8"},"?":{"ha":653,"x_min":18,"x_max":628,"o":"m 254 262 l 254 262 q 254 309 279 341 q 304 374 357 402 l 357 402 l 433 443 q 464 460 483 483 q 502 507 502 535 l 502 535 q 502 591 457 624 q 411 657 331 657 l 331 657 q 252 657 202 620 q 152 584 144 520 l 144 520 l 18 520 q 27 635 110 699 q 192 764 331 764 l 331 764 q 471 764 550 705 q 628 646 628 542 l 628 542 q 628 492 598 448 q 568 404 511 374 l 511 374 l 430 331 q 401 315 386 297 q 370 280 370 253 l 370 253 l 370 221 l 259 221 q 256 232 255 242 q 254 252 254 262 m 312 -8 l 312 -8 q 277 -8 254 13 q 231 35 231 69 l 231 69 q 231 103 254 124 q 277 146 312 146 l 312 146 q 348 146 371 124 q 393 103 393 69 l 393 69 q 393 35 371 13 q 348 -8 312 -8"},"—":{"ha":1162,"x_min":59,"x_max":1103,"o":"m 59 302 l 59 402 l 586 402 l 586 302 l 59 302 m 576 302 l 576 402 l 1103 402 l 1103 302 l 576 302"},"-":{"ha":351,"x_min":59,"x_max":292,"o":"m 59 298 l 59 405 l 292 405 l 292 298 l 59 298"},"–":{"ha":645,"x_min":59,"x_max":586,"o":"m 59 302 l 59 402 l 586 402 l 586 302 l 59 302"},"«":{"ha":619,"x_min":47,"x_max":571,"o":"m 47 313 l 198 540 l 333 540 l 165 291 l 165 335 l 333 86 l 198 86 l 47 313 m 285 313 l 436 540 l 571 540 l 403 291 l 403 335 l 571 86 l 436 86 l 285 313"},"»":{"ha":619,"x_min":48,"x_max":572,"o":"m 286 540 l 421 540 l 572 313 l 421 86 l 286 86 l 454 335 l 454 291 l 286 540 m 48 540 l 183 540 l 334 313 l 183 86 l 48 86 l 216 335 l 216 291 l 48 540"},"\"":{"ha":422,"x_min":50,"x_max":371,"o":"m 348 480 l 270 480 l 247 750 l 371 750 l 348 480 m 151 480 l 73 480 l 50 750 l 174 750 l 151 480"},"(":{"ha":331,"x_min":43,"x_max":306,"o":"m 200 750 l 200 750 l 306 750 q 240 641 209 536 q 177 432 177 324 l 177 324 q 177 218 209 113 q 240 9 306 -101 l 306 -101 l 200 -101 q 121 3 82 107 q 43 212 43 324 l 43 324 q 43 437 82 542 q 121 647 200 750"},")":{"ha":331,"x_min":25,"x_max":288,"o":"m 25 750 l 131 750 q 210 647 249 542 q 288 437 288 324 l 288 324 q 288 212 249 107 q 210 3 131 -101 l 131 -101 l 25 -101 q 91 9 123 113 q 154 218 154 324 l 154 324 q 154 432 123 536 q 91 641 25 750 l 25 750"},"/":{"ha":421,"x_min":16,"x_max":405,"o":"m 16 -27 l 296 777 l 405 777 l 125 -27 l 16 -27"},"%":{"ha":1211,"x_min":27,"x_max":1186,"o":"m 271 319 l 271 319 q 199 319 144 346 q 89 374 58 424 q 27 474 27 539 l 27 539 q 27 604 58 653 q 89 703 144 730 q 199 758 271 758 l 271 758 q 344 758 399 730 q 453 703 484 653 q 515 604 515 539 l 515 539 q 515 474 484 424 q 453 374 399 346 q 344 319 271 319 m 271 402 l 271 402 q 335 402 372 438 q 409 475 409 539 l 409 539 q 409 602 372 639 q 335 676 271 676 l 271 676 q 207 676 170 639 q 133 602 133 539 l 133 539 q 133 475 170 438 q 207 402 271 402 m 942 -12 l 942 -12 q 870 -12 815 15 q 760 43 729 93 q 698 143 698 208 l 698 208 q 698 273 729 322 q 760 372 815 399 q 870 427 942 427 l 942 427 q 1015 427 1070 399 q 1124 372 1155 322 q 1186 273 1186 208 l 1186 208 q 1186 143 1155 93 q 1124 43 1070 15 q 1015 -12 942 -12 m 942 71 l 942 71 q 1006 71 1043 107 q 1080 144 1080 208 l 1080 208 q 1080 271 1043 308 q 1006 345 942 345 l 942 345 q 878 345 841 308 q 804 271 804 208 l 804 208 q 804 144 841 107 q 878 71 942 71 m 353 0 l 759 750 l 862 750 l 455 0 l 353 0"},"+":{"ha":620,"x_min":30,"x_max":590,"o":"m 360 74 l 260 74 l 260 634 l 360 634 l 360 74 m 590 304 l 30 304 l 30 404 l 590 404 l 590 304"},"₽":{"ha":805,"x_min":49,"x_max":778,"o":"m 520 654 l 520 654 l 238 654 l 297 714 l 297 0 l 165 0 l 165 750 l 527 750 q 604 750 660 722 q 716 694 747 644 q 778 594 778 527 l 778 527 q 778 461 747 411 q 716 361 660 333 q 604 305 527 305 l 527 305 l 50 305 l 50 401 l 520 401 q 577 401 611 435 q 644 469 644 527 l 644 527 q 644 586 611 620 q 577 654 520 654 m 632 126 l 49 126 l 49 212 l 632 212 l 632 126"},"№":{"ha":1460,"x_min":84,"x_max":1426,"o":"m 952 142 l 952 218 l 1361 218 l 1361 142 l 952 142 m 1157 294 l 1157 294 q 1077 294 1017 323 q 956 352 922 404 q 888 457 888 526 l 888 526 q 888 595 922 647 q 956 699 1017 728 q 1078 758 1157 758 l 1157 758 q 1237 758 1298 729 q 1358 700 1392 648 q 1426 596 1426 526 l 1426 526 q 1426 457 1392 404 q 1358 352 1298 323 q 1237 294 1157 294 m 1157 385 l 1157 385 q 1227 385 1268 423 q 1309 461 1309 526 l 1309 526 q 1309 592 1268 629 q 1227 667 1157 667 l 1157 667 q 1087 667 1046 629 q 1005 592 1005 526 l 1005 526 q 1005 460 1046 422 q 1087 385 1157 385 m 250 750 l 707 77 l 659 71 l 659 750 l 784 750 l 784 0 l 622 0 l 161 679 l 208 685 l 208 0 l 84 0 l 84 750 l 250 750"}},"familyName":"font","ascender":995,"descender":-245,"underlinePosition":-100,"underlineThickness":50,"boundingBox":{"yMin":-245,"xMin":0,"yMax":995,"xMax":1000},"resolution":1000,"original_font_information":{"full_font_name":""}} \ No newline at end of file diff --git a/landing-3d/public/models/apparat.glb b/landing-3d/public/models/apparat.glb new file mode 100644 index 00000000..72ce478b Binary files /dev/null and b/landing-3d/public/models/apparat.glb differ diff --git a/landing-3d/public/models/koral.glb b/landing-3d/public/models/koral.glb new file mode 100644 index 00000000..8dc1150f Binary files /dev/null and b/landing-3d/public/models/koral.glb differ diff --git a/landing-3d/public/tex/depth-test.png b/landing-3d/public/tex/depth-test.png new file mode 100644 index 00000000..dfc77dcf Binary files /dev/null and b/landing-3d/public/tex/depth-test.png differ diff --git a/landing-3d/public/video/voronka.mp4 b/landing-3d/public/video/voronka.mp4 new file mode 100644 index 00000000..0ac6adca Binary files /dev/null and b/landing-3d/public/video/voronka.mp4 differ diff --git a/landing-3d/resursy.html b/landing-3d/resursy.html new file mode 100644 index 00000000..5cf49421 --- /dev/null +++ b/landing-3d/resursy.html @@ -0,0 +1,31 @@ + + + + + + Картина ресурсов — что мы умеем на практике + + + +
загрузка…
+
+ + + + + diff --git a/landing-3d/src/probe.js b/landing-3d/src/probe.js new file mode 100644 index 00000000..985a3af0 --- /dev/null +++ b/landing-3d/src/probe.js @@ -0,0 +1,137 @@ +// Проба одной вещи: оживает ли зеркальный металл и стекло от карты окружения. +// Датчик: без карты хром рисуется ровной белёсой лентой; с картой на нём видны +// бирюзовые пятна и золотые точки. Проверяем ЦИФРОЙ — разбросом цвета по пикселям хрома. +import * as THREE from 'three'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; + +const stat = document.getElementById('stat'); +const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); +renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); +renderer.setSize(innerWidth, innerHeight); +renderer.toneMapping = THREE.ACESFilmicToneMapping; +renderer.toneMappingExposure = 1.15; +document.body.appendChild(renderer.domElement); + +const scene = new THREE.Scene(); +scene.background = new THREE.Color(0x012019); +const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 200); +camera.position.set(0, 0.6, 7); +camera.lookAt(0, 0, 0); + +// --- карта окружения --- +const pmrem = new THREE.PMREMGenerator(renderer); +pmrem.compileEquirectangularShader(); + +const params = new URLSearchParams(location.search); +const withEnv = params.get('env') !== 'off'; + +const loader = new THREE.TextureLoader(); +const chrome = new THREE.MeshStandardMaterial({ color: 0xffffff, metalness: 1, roughness: 0.04 }); +const glass = new THREE.MeshPhysicalMaterial({ + color: 0xffffff, metalness: 0, roughness: 0.02, + transmission: 1, thickness: 1.2, ior: 1.5, iridescence: 0.6, iridescenceIOR: 1.4, +}); +const metalDark = new THREE.MeshStandardMaterial({ color: 0x8fa3a0, metalness: 0.9, roughness: 0.35 }); + +// кольцо — единственный зеркальный предмет, как в эталоне +const ring = new THREE.Mesh(new THREE.TorusGeometry(1.75, 0.24, 64, 200), chrome); +ring.position.set(-1.6, 0.2, 0); +scene.add(ring); + +// стеклянный многогранник +const gem = new THREE.Mesh(new THREE.IcosahedronGeometry(0.95, 0), glass); +gem.position.set(1.9, 0.1, 0.6); +scene.add(gem); + +// слабый заполняющий свет — чтобы без карты сцена не была чёрной (честное сравнение) +scene.add(new THREE.AmbientLight(0xffffff, 0.25)); +const key = new THREE.DirectionalLight(0xffffff, 1.1); +key.position.set(2, 3, 4); +scene.add(key); + +let apparat = null; +const done = { env: !withEnv, glb: false }; + +if (withEnv) { + loader.load('/env/studio-teal.jpg', (tex) => { + tex.mapping = THREE.EquirectangularReflectionMapping; + tex.colorSpace = THREE.SRGBColorSpace; + const env = pmrem.fromEquirectangular(tex).texture; + scene.environment = env; + tex.dispose(); + done.env = true; + }); +} + +new GLTFLoader().load('/models/apparat.glb', (g) => { + apparat = g.scene; + const box = new THREE.Box3().setFromObject(apparat); + const size = box.getSize(new THREE.Vector3()); + const s = 2.6 / Math.max(size.x, size.y, size.z); + apparat.scale.setScalar(s); + const c = box.getCenter(new THREE.Vector3()).multiplyScalar(s); + apparat.position.set(-c.x, -c.y - 1.7, -c.z - 2); + apparat.traverse((o) => { if (o.isMesh) o.material = metalDark; }); + scene.add(apparat); + done.glb = true; +}, undefined, () => { done.glb = true; }); + +// --- датчик: насколько разноцветный зеркальный предмет --- +function measure() { + const w = renderer.domElement.width, h = renderer.domElement.height; + const gl = renderer.getContext(); + const px = new Uint8Array(w * h * 4); + gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); + // смотрим только левую половину — там кольцо + let n = 0, sumSat = 0, sumHueVar = 0, hues = []; + for (let y = 0; y < h; y += 3) { + for (let x = 0; x < Math.floor(w * 0.55); x += 3) { + const i = (y * w + x) * 4; + const r = px[i], g2 = px[i + 1], b = px[i + 2]; + const mx = Math.max(r, g2, b), mn = Math.min(r, g2, b); + if (mx < 40) continue; // фон отбрасываем + const sat = mx === 0 ? 0 : (mx - mn) / mx; + sumSat += sat; n++; + if (sat > 0.08) hues.push(Math.atan2(Math.sqrt(3) * (g2 - b), 2 * r - g2 - b)); + } + } + const avgSat = n ? sumSat / n : 0; + // разброс оттенка + let hv = 0; + if (hues.length > 8) { + const mh = hues.reduce((a, b) => a + b, 0) / hues.length; + hv = Math.sqrt(hues.reduce((a, b) => a + (b - mh) ** 2, 0) / hues.length); + } + sumHueVar = hv; + return { pix: n, avgSat: +avgSat.toFixed(4), hueSpread: +sumHueVar.toFixed(4), colored: hues.length }; +} + +let frames = 0; +function tick() { + requestAnimationFrame(tick); + const t = performance.now() * 0.0002; + ring.rotation.set(0.5 + t * 0.5, t, 0); + gem.rotation.set(t * 0.9, t * 1.3, 0); + if (apparat) apparat.rotation.y = t * 0.4; + renderer.render(scene, camera); + frames++; + if (frames === 60 || frames % 240 === 0) { + const m = measure(); + const verdict = m.avgSat > 0.12 && m.colored > 300 ? 'ЖИВОЙ' : 'МЁРТВЫЙ (белая лента)'; + stat.textContent = + `карта окружения: ${withEnv ? 'ВКЛ' : 'ВЫКЛ'}\n` + + `пикселей предмета: ${m.pix}\n` + + `средняя насыщенность: ${m.avgSat}\n` + + `цветных пикселей: ${m.colored}\n` + + `разброс оттенка: ${m.hueSpread}\n` + + `вердикт: ${verdict}`; + window.__probe = { ...m, withEnv, verdict, lost: renderer.getContext().isContextLost() }; + } +} +tick(); + +addEventListener('resize', () => { + camera.aspect = innerWidth / innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(innerWidth, innerHeight); +}); diff --git a/landing-3d/src/resursy.js b/landing-3d/src/resursy.js new file mode 100644 index 00000000..0aa72fb3 --- /dev/null +++ b/landing-3d/src/resursy.js @@ -0,0 +1,289 @@ +// КАРТИНА РЕСУРСОВ. Один экран, на котором каждая наша возможность показана в деле. +// Ничего не утверждаем словами — всё либо видно глазом, либо посчитано цифрой. +import * as THREE from 'three'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js'; +import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js'; + +const hud = document.getElementById('hud'); +const podpisi = document.getElementById('podpisi'); +const ok = {}; // что доказано +const meток = []; // подписи, привязанные к предметам + +const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' }); +renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); +renderer.setSize(innerWidth, innerHeight); +renderer.toneMapping = THREE.ACESFilmicToneMapping; +renderer.toneMappingExposure = 1.1; +document.body.appendChild(renderer.domElement); + +// что вообще умеет эта видеокарта — стекло без плавающих буферов рисуется чёрным +const glctx = renderer.getContext(); +const vozmozhnosti = { + webgl2: renderer.capabilities.isWebGL2, + floatBuffer: !!glctx.getExtension('EXT_color_buffer_float'), + floatLinear: !!glctx.getExtension('OES_texture_float_linear'), + halfFloatLinear: !!glctx.getExtension('OES_texture_half_float_linear'), + anisotropy: renderer.capabilities.getMaxAnisotropy(), + maxTexture: renderer.capabilities.maxTextureSize, + renderer: (() => { const d = glctx.getExtension('WEBGL_debug_renderer_info'); return d ? glctx.getParameter(d.UNMASKED_RENDERER_WEBGL) : '?'; })(), +}; + +const scene = new THREE.Scene(); +scene.background = new THREE.Color(0x01120e); +scene.fog = new THREE.FogExp2(0x01120e, 0.018); +const camera = new THREE.PerspectiveCamera(42, innerWidth / innerHeight, 0.1, 400); +camera.position.set(0, 1.2, 22); + +scene.add(new THREE.AmbientLight(0xffffff, 0.15)); +const key = new THREE.DirectionalLight(0xffffff, 0.8); key.position.set(4, 8, 6); scene.add(key); + +function podpis(obj, text) { meток.push({ obj, el: Object.assign(document.createElement('b'), { textContent: text }) }); podpisi.appendChild(meток[meток.length - 1].el); } + +// ─────────── 1. КАРТА ОКРУЖЕНИЯ → живой зеркальный металл ─────────── +const pmrem = new THREE.PMREMGenerator(renderer); +pmrem.compileEquirectangularShader(); +const chrome = new THREE.MeshStandardMaterial({ color: 0xffffff, metalness: 1, roughness: 0.045, envMapIntensity: 1.6 }); +const ring = new THREE.Mesh(new THREE.TorusGeometry(2.0, 0.28, 48, 180), chrome); +ring.position.set(-11, 1.2, 0); ring.rotation.set(0.45, -0.5, 0.1); +scene.add(ring); podpis(ring, '1 · хром + карта окружения'); + +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(); ok.karta = true; +}); + +// ─────────── 2. СТЕКЛО с преломлением (позади него — светящееся) ─────────── +const glass = new THREE.MeshPhysicalMaterial({ + color: 0xffffff, metalness: 0, roughness: 0.03, + transmission: 1, thickness: 1.6, ior: 1.52, + iridescence: 0.5, iridescenceIOR: 1.35, envMapIntensity: 1.4, +}); +const gem = new THREE.Mesh(new THREE.IcosahedronGeometry(1.1, 0), glass); +gem.position.set(-7.2, 2.2, 0.4); scene.add(gem); podpis(gem, '2а · transmission=1'); + +// вариант «б»: полупрозрачность + отражение окружения, без плавающих буферов +const glassB = 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, +}); +const gemB = new THREE.Mesh(new THREE.IcosahedronGeometry(1.1, 0), glassB); +gemB.position.set(-4.6, 2.2, 0.4); scene.add(gemB); podpis(gemB, '2б · полупрозрачность + отражение'); + +// вариант «в»: transmission поменьше, толщина побольше, без тумана на материале +const glassC = new THREE.MeshPhysicalMaterial({ + color: 0xffffff, metalness: 0, roughness: 0.02, envMapIntensity: 1.8, + transmission: 0.92, thickness: 0.6, ior: 1.35, specularIntensity: 1, +}); +glassC.fog = false; +const gemC = new THREE.Mesh(new THREE.IcosahedronGeometry(1.1, 0), glassC); +gemC.position.set(-6.0, -0.3, 0.4); scene.add(gemC); podpis(gemC, '2в · transmission=0.92, тонкое'); +// то, что стекло обязано преломить — светящаяся решётка позади +const zaGlass = new THREE.Mesh( + new THREE.PlaneGeometry(6, 6, 1, 1), + new THREE.MeshBasicMaterial({ color: 0x4FD1A5, wireframe: false, transparent: true, opacity: 0.0 }) +); +zaGlass.position.set(-5.6, 1.1, -3.5); scene.add(zaGlass); +const setka = new THREE.LineSegments( + new THREE.WireframeGeometry(new THREE.PlaneGeometry(7, 7, 9, 9)), + new THREE.LineBasicMaterial({ color: 0x4FD1A5, transparent: true, opacity: 0.85 }) +); +setka.position.set(-5.6, 1.1, -3.4); scene.add(setka); +ok.steklo = true; + +// ─────────── 3. ОБЪЁМНЫЕ РУССКИЕ БУКВЫ ─────────── +const bukvy = new THREE.MeshStandardMaterial({ color: 0xF6F3EC, metalness: 0.35, roughness: 0.28, envMapIntensity: 1.2 }); +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, + }); + g.computeBoundingBox(); + const m = new THREE.Mesh(g, bukvy); + const bb = g.boundingBox; + m.position.set(-((bb.max.x - bb.min.x) / 2) + 0.6, 1.9, 0); + m.rotation.y = -0.12; + scene.add(m); podpis(m, '3 · объёмные русские буквы (Unbounded)'); + ok.bukvy = true; +}, undefined, () => { ok.bukvy = false; }); + +// ─────────── 4. ВИДЕО ВНУТРИ СТЕКЛЯННОЙ ПАНЕЛИ ─────────── +const video = document.getElementById('v'); +const vtex = new THREE.VideoTexture(video); +vtex.colorSpace = THREE.SRGBColorSpace; +video.play().then(() => { ok.video = true; }).catch(() => { ok.video = false; }); + +function panel(x, y, z, ry, w, h, mat) { + const shape = new THREE.Shape(); + const r = 0.22, hw = w / 2, hh = h / 2; + shape.moveTo(-hw + r, -hh); + shape.lineTo(hw - r, -hh); shape.quadraticCurveTo(hw, -hh, hw, -hh + r); + shape.lineTo(hw, hh - r); shape.quadraticCurveTo(hw, hh, hw - r, hh); + shape.lineTo(-hw + r, hh); shape.quadraticCurveTo(-hw, hh, -hw, hh - r); + shape.lineTo(-hw, -hh + r); shape.quadraticCurveTo(-hw, -hh, -hw + r, -hh); + const g = new THREE.ExtrudeGeometry(shape, { depth: 0.09, bevelEnabled: true, bevelThickness: 0.03, bevelSize: 0.03, bevelSegments: 2 }); + g.center(); + // ровные координаты текстуры на лицевой грани + 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; + const m = new THREE.Mesh(g, mat); + m.position.set(x, y, z); m.rotation.y = ry; + scene.add(m); return m; +} + +const matVideo = new THREE.MeshPhysicalMaterial({ + map: vtex, emissive: 0xffffff, emissiveMap: vtex, emissiveIntensity: 0.9, + color: 0x223a34, metalness: 0.1, roughness: 0.12, envMapIntensity: 0.8, + transparent: true, opacity: 0.96, +}); +const pV = panel(1.4, 1.0, -1.2, -0.42, 6.2, 3.5, matVideo); +podpis(pV, '4 · видео внутри стеклянной панели'); + +// ─────────── 5. КАРТА ГЛУБИНЫ → объём у плоской картинки ─────────── +const depthTex = new THREE.TextureLoader().load('/tex/depth-test.png', () => { ok.glubina = true; }); +const colorTex = new THREE.TextureLoader().load('/env/studio-teal.jpg'); +colorTex.colorSpace = THREE.SRGBColorSpace; +const matParallax = new THREE.ShaderMaterial({ + uniforms: { tCol: { value: colorTex }, tDep: { value: depthTex }, uShift: { value: new THREE.Vector2() }, uAmp: { value: 0.055 } }, + vertexShader: `varying vec2 vUv; void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.); }`, + fragmentShader: ` + uniform sampler2D tCol, tDep; uniform vec2 uShift; uniform float uAmp; varying vec2 vUv; + void main(){ + float d = texture2D(tDep, vUv).r; + vec2 uv = vUv + uShift * (d - 0.5) * uAmp * 8.0; + vec3 c = texture2D(tCol, uv).rgb; + gl_FragColor = vec4(c, 1.0); + }`, +}); +const pD = panel(8.4, 1.0, -0.6, -0.55, 5.4, 3.0, matParallax); +podpis(pD, '5 · плоская картинка + карта глубины = объём'); + +// ─────────── 6. ГОТОВЫЕ ОБЪЁМНЫЕ ПРЕДМЕТЫ ОТ МОДЕЛЕЙ ─────────── +const metal = new THREE.MeshStandardMaterial({ color: 0x9fb4b0, metalness: 0.85, roughness: 0.32, envMapIntensity: 1.3 }); +const koralMat = new THREE.MeshStandardMaterial({ color: 0x2fd6a8, metalness: 0.1, roughness: 0.55, emissive: 0x0d3b30, envMapIntensity: 1.0 }); +const gl = new GLTFLoader(); +function stavim(url, mat, pos, size, name, flag) { + gl.load(url, (g) => { + const o = g.scene; + const b = new THREE.Box3().setFromObject(o), s = b.getSize(new THREE.Vector3()); + const k = size / Math.max(s.x, s.y, s.z); + o.scale.setScalar(k); + const c = b.getCenter(new THREE.Vector3()).multiplyScalar(k); + o.position.set(pos[0] - c.x, pos[1] - c.y, pos[2] - c.z); + o.traverse((n) => { if (n.isMesh) n.material = mat; }); + scene.add(o); podpis(o, name); ok[flag] = true; + }, undefined, () => { ok[flag] = false; }); +} +stavim('/models/apparat.glb', metal, [14.5, 0.4, -2], 5.0, '6 · предмет от Tripo3D (50 ₽)', 'glbA'); +stavim('/models/koral.glb', koralMat, [-15.5, -2.6, -1], 4.2, '7 · органика от Rodin (80 ₽)', 'glbB'); + +// ─────────── 8. ОБЛАКО ТОЧЕК (свой код, без моделей) ─────────── +const N = 26000; +const pos = new Float32Array(N * 3), col = new Float32Array(N * 3), sz = new Float32Array(N); +const c1 = new THREE.Color(0x4FD1A5), c2 = new THREE.Color(0xF6A03C); +for (let i = 0; i < N; i++) { + // «коралловые» кусты: несколько центров, вокруг — степенное распределение + const kust = Math.floor(Math.random() * 7); + const cx = [-13, -7, -1, 4, 9, 14, 0][kust], cy = [1, -2, 3, -1, 2, -2, -4][kust], cz = [-6, -9, -12, -7, -10, -8, -14][kust]; + const r = Math.pow(Math.random(), 2.2) * 4.6; + const t = Math.random() * Math.PI * 2, p = Math.acos(2 * Math.random() - 1); + pos[i * 3] = cx + r * Math.sin(p) * Math.cos(t); + pos[i * 3 + 1] = cy + r * Math.cos(p) * 0.75; + pos[i * 3 + 2] = cz + r * Math.sin(p) * Math.sin(t); + const c = Math.random() < 0.14 ? c2 : c1; + const f = 0.45 + Math.random() * 0.55; + col[i * 3] = c.r * f; col[i * 3 + 1] = c.g * f; col[i * 3 + 2] = c.b * f; + sz[i] = 0.02 + Math.random() * 0.07; +} +const pg = new THREE.BufferGeometry(); +pg.setAttribute('position', new THREE.BufferAttribute(pos, 3)); +pg.setAttribute('color', new THREE.BufferAttribute(col, 3)); +pg.setAttribute('aSize', new THREE.BufferAttribute(sz, 1)); +const pmat = new THREE.ShaderMaterial({ + uniforms: { uT: { value: 0 } }, + vertexShader: ` + attribute float aSize; varying vec3 vC; uniform float uT; + void main(){ vC=color; vec3 p=position; + p.y += sin(uT*0.6 + p.x*0.7)*0.09; p.x += cos(uT*0.4 + p.z*0.5)*0.07; + vec4 mv=modelViewMatrix*vec4(p,1.); + gl_PointSize = aSize * 620.0 / -mv.z; gl_Position=projectionMatrix*mv; }`, + fragmentShader: ` + varying vec3 vC; + void main(){ vec2 d=gl_PointCoord-0.5; float a=smoothstep(0.5,0.06,length(d)); + if(a<0.02) discard; gl_FragColor=vec4(vC, a); }`, + transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexColors: true, +}); +const points = new THREE.Points(pg, pmat); +scene.add(points); podpis(points, '8 · облако точек, 26 000 — свой код, бесплатно'); +ok.tochki = true; + +// ─────────── замеры ─────────── +let last = performance.now(), fps = 0, sm = 0, n = 0; +const v2 = new THREE.Vector3(); +function tick() { + requestAnimationFrame(tick); + const now = performance.now(), dt = now - last; last = now; + fps = 1000 / dt; sm = sm ? sm * 0.92 + fps * 0.08 : fps; n++; + const t = now * 0.001; + + camera.position.x = Math.sin(t * 0.09) * 3.2; + camera.position.y = 1.2 + Math.sin(t * 0.13) * 0.6; + camera.lookAt(0, 1.0, -2); + ring.rotation.y += 0.004; ring.rotation.x += 0.0016; + gem.rotation.y += 0.006; gem.rotation.x += 0.003; + pmat.uniforms.uT.value = t; + matParallax.uniforms.uShift.value.set(Math.sin(t * 0.5) * 0.5, Math.cos(t * 0.37) * 0.3); + + renderer.render(scene, camera); + + // подписи над предметами + for (const m of meток) { + m.obj.getWorldPosition(v2); v2.project(camera); + const vis = v2.z < 1 && Math.abs(v2.x) < 1.25; + m.el.style.display = vis ? 'block' : 'none'; + if (vis) { m.el.style.left = ((v2.x * 0.5 + 0.5) * innerWidth) + 'px'; m.el.style.top = ((-v2.y * 0.5 + 0.5) * innerHeight - 26) + 'px'; } + } + + if (n % 30 === 0) { + const i = renderer.info; + const строки = [ + `кадров в секунду: ${sm.toFixed(0)}`, + `вызовов отрисовки: ${i.render.calls}`, + `треугольников: ${i.render.triangles.toLocaleString('ru')}`, + `текстур в памяти: ${i.memory.textures} геометрий: ${i.memory.geometries}`, + '', + `карта окружения: ${ok.karta ? 'ДА' : '…'}`, + `стекло с преломлением:${ok.steklo ? ' ДА' : ' …'}`, + `объёмные рус. буквы: ${ok.bukvy === undefined ? '…' : ok.bukvy ? 'ДА' : 'НЕТ'}`, + `видео в панели: ${ok.video === undefined ? '…' : ok.video ? 'ДА' : 'НЕТ'} (кадр ${video.readyState})`, + `карта глубины: ${ok.glubina ? 'ДА' : '…'}`, + `предмет Tripo3D: ${ok.glbA === undefined ? '…' : ok.glbA ? 'ДА' : 'НЕТ'}`, + `органика Rodin: ${ok.glbB === undefined ? '…' : ok.glbB ? 'ДА' : 'НЕТ'}`, + `облако точек: ${ok.tochki ? 'ДА' : '…'}`, + '', + `видеокарта: ${String(vozmozhnosti.renderer).slice(0, 42)}`, + `WebGL2 ${vozmozhnosti.webgl2 ? 'да' : 'НЕТ'} · плавающий буфер ${vozmozhnosti.floatBuffer ? 'да' : 'НЕТ'} · сглаживание его ${vozmozhnosti.floatLinear ? 'да' : 'НЕТ'}`, + `(без плавающего буфера стекло рисуется чёрным — это ограничение машины, не кода)`, + ]; + hud.textContent = строки.join('\n'); + window.__kartina = { + fps: +sm.toFixed(1), calls: i.render.calls, triangles: i.render.triangles, + textures: i.memory.textures, ...ok, videoState: video.readyState, + ctxLost: renderer.getContext().isContextLost(), ...vozmozhnosti, + }; + } +} +tick(); + +addEventListener('resize', () => { + camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); + renderer.setSize(innerWidth, innerHeight); +}); diff --git a/landing-3d/tools/font2typeface.mjs b/landing-3d/tools/font2typeface.mjs new file mode 100644 index 00000000..653a60b0 --- /dev/null +++ b/landing-3d/tools/font2typeface.mjs @@ -0,0 +1,61 @@ +// Переводит TTF в формат, который понимают объёмные буквы three.js (TextGeometry). +// Берём только нужные знаки — русские заглавные, цифры, немного латиницы и знаков, +// иначе файл распухает впустую. +import fs from 'node:fs'; +import opentype from 'opentype.js'; + +const src = process.argv[2]; +const dst = process.argv[3]; +const CHARS = + 'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ' + + 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя' + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + + '0123456789' + + ' .,:;!?—-–«»""()/%+₽№'; + +const font = opentype.parse(fs.readFileSync(src).buffer); +const upm = font.unitsPerEm; +const scale = 1000 / upm; // three.js ждёт разрешение 1000 +const r = (v) => Math.round(v * scale); +// opentype рисует с осью Y вниз, three.js ждёт вверх — иначе буквы встают вверх ногами +const ry = (v) => -Math.round(v * scale); + +const glyphs = {}; +let miss = []; +for (const ch of new Set(CHARS)) { + const g = font.charToGlyph(ch); + if (!g || g.index === 0) { miss.push(ch); continue; } + const path = g.getPath(0, 0, upm); // рисуем в единицах шрифта + let o = ''; + for (const c of path.commands) { + if (c.type === 'M') o += `m ${r(c.x)} ${ry(c.y)} `; + else if (c.type === 'L') o += `l ${r(c.x)} ${ry(c.y)} `; + else if (c.type === 'Q') o += `q ${r(c.x1)} ${ry(c.y1)} ${r(c.x)} ${ry(c.y)} `; + else if (c.type === 'C') o += `b ${r(c.x1)} ${ry(c.y1)} ${r(c.x2)} ${ry(c.y2)} ${r(c.x)} ${ry(c.y)} `; + else if (c.type === 'Z') o += ''; + } + const bb = path.getBoundingBox(); + glyphs[ch] = { + ha: r(g.advanceWidth), + x_min: r(bb.x1 || 0), + x_max: r(bb.x2 || 0), + o: o.trim(), + }; +} + +const out = { + glyphs, + familyName: font.names.fontFamily?.en || 'font', + ascender: r(font.ascender), + descender: r(font.descender), + underlinePosition: -100, + underlineThickness: 50, + boundingBox: { yMin: r(font.descender), xMin: 0, yMax: r(font.ascender), xMax: 1000 }, + resolution: 1000, + original_font_information: { full_font_name: font.names.fullName?.en || '' }, +}; + +fs.writeFileSync(dst, JSON.stringify(out)); +const kb = (fs.statSync(dst).size / 1024).toFixed(0); +console.log(`знаков: ${Object.keys(glyphs).length}, файл ${kb} КБ`); +if (miss.length) console.log('не нашлись:', miss.join('')); diff --git a/landing-3d/tools/shot.mjs b/landing-3d/tools/shot.mjs new file mode 100644 index 00000000..b97780dc --- /dev/null +++ b/landing-3d/tools/shot.mjs @@ -0,0 +1,48 @@ +// Снимок страницы + чтение датчика window.__probe. +// Флаги: только --use-angle=swiftshader, НЕ --use-gl=swiftshader (тот молча теряет контекст). +import { chromium } from 'playwright'; + +const url = process.argv[2] || 'http://localhost:5199/probe.html'; +const out = process.argv[3] || 'shot.png'; +const wait = Number(process.argv[4] || 6000); +const w = Number(process.argv[5] || 1440); +const h = Number(process.argv[6] || 900); + +const browser = await chromium.launch({ + args: [ + '--use-angle=swiftshader', + '--enable-unsafe-swiftshader', + '--enable-webgl', + '--ignore-gpu-blocklist', + ], +}); +const page = await browser.newPage({ viewport: { width: w, height: h }, deviceScaleFactor: 1 }); +const errs = []; +page.on('console', (m) => { if (m.type() === 'error') errs.push(m.text()); }); +page.on('pageerror', (e) => errs.push('pageerror: ' + e.message)); + +// замедление процессора — грубая замена телефону (так же меряет Lighthouse) +const slow = Number(process.env.CPU_SLOW || 0); +if (slow > 1) { + const cdp = await page.context().newCDPSession(page); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: slow }); +} + +await page.goto(url, { waitUntil: 'load', timeout: 60000 }); +await page.waitForTimeout(wait); + +const probe = await page.evaluate(() => ({ + probe: window.__kartina || window.__probe || null, + ctxLost: (() => { + const c = document.querySelector('canvas'); + if (!c) return 'нет холста'; + const gl = c.getContext('webgl2') || c.getContext('webgl'); + return gl ? gl.isContextLost() : 'нет контекста'; + })(), + canvas: (() => { const c = document.querySelector('canvas'); return c ? `${c.width}x${c.height}` : null; })(), +})); + +await page.screenshot({ path: out }); +await browser.close(); + +console.log(JSON.stringify({ url, out, ...probe, errors: errs.slice(0, 8) }, null, 1));