From 90692f0f164f6c0b6f5f079ced29ea032ff37af2 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 31 Jul 2026 09:20:37 +0300 Subject: [PATCH] raise editorial quality gate and revise 2018 spring --- editorial/QUALITY_STANDARD.md | 21 +- editorial/README.md | 2 + editorial/production/README.md | 24 + editorial/production/archive-rewrite-queue.md | 1084 +++++++++++++++++ editorial/reviews/2018-01.md | 2 +- editorial/reviews/2018-02-draft.md | 65 + editorial/reviews/2018-03-draft.md | 65 + editorial/reviews/2018-04-draft.md | 34 + editorial/reviews/2018-05-draft.md | 62 + editorial/reviews/TEMPLATE.md | 58 + .../voice/author-trajectory-2017-2027.md | 279 +++++ web/app/globals.css | 9 + web/data/articles.json | 81 +- web/data/editorial-revisions.mjs | 8 + web/lib/articles.js | 14 +- .../editorial/2018/bitrix-slug-build-2018.svg | 66 + .../2018/bitrix-slug-conflict-2018.svg | 54 + .../editorial/2018/bitrix-slug-route-2018.svg | 64 + .../2018/curl-outcome-classifier.svg | 46 + .../2018/jquery-ajax-form-contract.svg | 73 ++ .../2018/jquery-delegation-after-html.svg | 69 ++ .../2018/jquery-reinit-namespaces.svg | 58 + .../2018/json-payload-diagnostic.svg | 44 + .../editorial/2018/php-fatal-context-flow.svg | 51 + .../2018/php-private-download-flow.svg | 76 ++ .../2018/php-upload-avatar-contract.svg | 91 ++ .../2018/php-upload-trust-signals.svg | 86 ++ web/scripts/audit-quality-batch.mjs | 63 +- web/scripts/upgrade-2018-01.mjs | 10 +- web/scripts/upgrade-2018-02.mjs | 498 ++++++++ web/scripts/upgrade-2018-03.mjs | 412 +++++++ web/scripts/upgrade-2018-04.mjs | 488 ++++++++ web/scripts/upgrade-2018-05.mjs | 558 +++++++++ 33 files changed, 4563 insertions(+), 52 deletions(-) create mode 100644 editorial/production/README.md create mode 100644 editorial/production/archive-rewrite-queue.md create mode 100644 editorial/reviews/2018-02-draft.md create mode 100644 editorial/reviews/2018-03-draft.md create mode 100644 editorial/reviews/2018-04-draft.md create mode 100644 editorial/reviews/2018-05-draft.md create mode 100644 editorial/reviews/TEMPLATE.md create mode 100644 editorial/voice/author-trajectory-2017-2027.md create mode 100644 web/data/editorial-revisions.mjs create mode 100644 web/public/assets/editorial/2018/bitrix-slug-build-2018.svg create mode 100644 web/public/assets/editorial/2018/bitrix-slug-conflict-2018.svg create mode 100644 web/public/assets/editorial/2018/bitrix-slug-route-2018.svg create mode 100644 web/public/assets/editorial/2018/curl-outcome-classifier.svg create mode 100644 web/public/assets/editorial/2018/jquery-ajax-form-contract.svg create mode 100644 web/public/assets/editorial/2018/jquery-delegation-after-html.svg create mode 100644 web/public/assets/editorial/2018/jquery-reinit-namespaces.svg create mode 100644 web/public/assets/editorial/2018/json-payload-diagnostic.svg create mode 100644 web/public/assets/editorial/2018/php-fatal-context-flow.svg create mode 100644 web/public/assets/editorial/2018/php-private-download-flow.svg create mode 100644 web/public/assets/editorial/2018/php-upload-avatar-contract.svg create mode 100644 web/public/assets/editorial/2018/php-upload-trust-signals.svg create mode 100644 web/scripts/upgrade-2018-02.mjs create mode 100644 web/scripts/upgrade-2018-03.mjs create mode 100644 web/scripts/upgrade-2018-04.mjs create mode 100644 web/scripts/upgrade-2018-05.mjs diff --git a/editorial/QUALITY_STANDARD.md b/editorial/QUALITY_STANDARD.md index 6213c8b..0616088 100644 --- a/editorial/QUALITY_STANDARD.md +++ b/editorial/QUALITY_STANDARD.md @@ -18,17 +18,36 @@ - Добавить минимум один рисунок или диаграмму, один пример и один проверяемый источник. - Закончить конкретным порядком действий, ограничениями и тем, что именно следует проверить в своём проекте. +## Объём и плотность + +- Основной текст статьи, без HTML-разметки, заголовка, метаданных и списка источников, занимает от **5 000 до 15 000 знаков**. +- Нижняя граница — не повод искусственно растягивать выводы. Если тема проста, глубину создают контекст, контрпример, проверка и решение, а не повтор одной мысли. +- Верхняя граница — повод разбить слишком широкую тему на серию. Одна статья отвечает на один главный вопрос. +- Каждый абзац либо добавляет факт, решение, ограничение или следующий шаг. Вступления «вообще о важности темы» и эмоциональные связки без технического смысла вырезаются. + +## Техническая речь + +- Пишем коротко и предметно: **симптом → причина → проверка → действие**. Предпочитаем глаголы и наблюдаемые факты: «запрос вернул 403», «фильтр исключает запись», «метрика выросла на 18%». +- Один абзац — одна мысль; одно предложение не пытается одновременно описать проблему, историю команды и решение. +- Термин используется только там, где он точнее обычного слова. После первого появления даём расшифровку или пример. +- Не используем общие оценки: «в современном мире», «очень важно», «магическая сила», «просто нужно учитывать». Вместо них называем условие, риск или ограничение. +- Заголовок обещает ровно тот вопрос, на который отвечает текст. Результат не объявляется «универсальным», если он зависит от версии, нагрузки, прав или архитектуры проекта. + ## Голос автора - Для 2017–2018 годов — практичная, тёплая заметка инженера: «давайте разберём», осторожные выводы, внимание к реальной ошибке и следующему шагу. +- Для 2019–2021 годов — инженер развивает T-shape: от PHP и Bitrix к фронтенду, инфраструктуре и данным. Текст всё ещё говорит от первого лица, но уже связывает решение с границами системы. +- Для 2022–2024 годов — системный практик: появляются измерения, надёжность, безопасность, доставка и взаимодействие ролей. Утверждения становятся проверяемее, а выводы — спокойнее. +- Для 2025–2027 годов — наставник и техлид: автор сравнивает варианты, называет стоимость решения, объясняет компромиссы и оставляет команде воспроизводимый способ работы. - Не подменять опыт общими фразами вроде «важно учитывать» или «магическая сила». Каждое обобщение должно опираться на случай, код, таблицу или источник. - Не делать вид, что исторический автор уже знает инструменты и практики 2027 года. Поздние материалы могут становиться системнее, но развитие должно быть постепенным. - Термины и сокращения раскрываются при первом появлении, если они не очевидны из контекста кода. +- Полная временная карта, словарь, переходы навыков и анти-анахронизмы находятся в `editorial/voice/author-trajectory-2017-2027.md`; она обязательна для редакторского прохода. ## Тройное ревью перед публикацией 1. **Факты и техника.** Сверить утверждения с источниками, проверить пример, версионные оговорки, ссылки и отсутствие ложных обещаний. -2. **Редактура и голос.** Проверить постановку проблемы, полноту раскрытия, естественность тона соответствующего года, повторы и ясность переходов. +2. **Редактура и голос.** Проверить постановку проблемы, объём 5–15 тыс. знаков, плотность, прагматичность речи, естественность тона соответствующего года, повторы и ясность переходов. 3. **Визуал и выпуск.** Открыть изображения и диаграммы, проверить таблицы на узком экране, доступность `alt`/подписей, JSON, автоматический аудит и production-сборку. Результат каждой ручной проверки фиксируется рядом с партией в `editorial/reviews/`. diff --git a/editorial/README.md b/editorial/README.md index a65630c..25818a0 100644 --- a/editorial/README.md +++ b/editorial/README.md @@ -34,3 +34,5 @@ ## Публикация Первичный массовый генератор `web/scripts/publishEditorialArchive.mjs` выведен из использования: он не соответствует редакционному стандарту и не должен перезаписывать доработанные статьи. Переработка идёт небольшими тематическими тройками поверх существующего архива. Для каждой тройки есть источник текста, автоматическая проверка, ручное трёхкратное ревью и проверка сборки. + +Текущая очередь, состояние архива и входной quality gate описаны в [производственном контуре](production/README.md). diff --git a/editorial/production/README.md b/editorial/production/README.md new file mode 100644 index 0000000..c32aae5 --- /dev/null +++ b/editorial/production/README.md @@ -0,0 +1,24 @@ +# Производство редакционных партий + +На 31 июля 2026 года строгий аудит проходит 3 из 358 созданных материалов. Остальные 355 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. + +## Одна партия + +Партия содержит три связанные, но не повторяющие друг друга статьи: + +1. практический разбор с воспроизводимым решением; +2. объяснение механизма и границ ответственности; +3. полевой кейс, диагностику или сравнение вариантов. + +Для каждой статьи автор готовит исследование, основной текст на 5 000–15 000 знаков, отдельный visual asset, таблицу, пример и список источников. Черновой скрипт не имеет права писать в `web/data/articles.json`: он только печатает ревизии через `--print-revisions`. + +## Вход в публикацию + +Основной редактор интегрирует партию только после того, как одновременно выполнены: + +- исследовательское ревью: ссылки проверены, версии и ограничения названы; +- редакторское ревью: проблема в начале, нет шаблонного языка, голос соответствует году; +- визуальное ревью: рисунки открываются, таблицы работают на 375px, у рисунков есть `alt` и подписи; +- `node --check`, XML-проверка диаграмм, `npm run audit:articles -- ` и production-сборка проходят. + +После этого рядом с партией появляется запись в `editorial/reviews/`, а изменение публикуется отдельным коммитом. Ни один скрипт не должен перегенерировать уже отревьюированный архив целиком. diff --git a/editorial/production/archive-rewrite-queue.md b/editorial/production/archive-rewrite-queue.md new file mode 100644 index 0000000..231e49f --- /dev/null +++ b/editorial/production/archive-rewrite-queue.md @@ -0,0 +1,1084 @@ +# Очередь полной замены редакционного архива + +> Инвентаризация выполнена по `web/data/articles.json`. Область работы — только статьи со slug `editorial-`. Три январские статьи 2018 года уже прошли ревью и в эту очередь не входят. + +## Граница и арифметика очереди + +- Всего slug `editorial-`: **358**. +- Исключены как уже отревьюированные: `editorial-2018-01-practice-bitrix-elements`, `editorial-2018-01-mechanism-bitrix-elements`, `editorial-2018-01-field-bitrix-elements`. +- К переписыванию остаются **355 статей**. +- В архиве две неполные календарные серии: октябрь 2018 и январь 2019 содержат по две статьи. Поэтому очередь состоит из **118 партий по три статьи** и одного явно помеченного одиночного выпуска `О-01`. Это не ошибка планирования: 355 не делится на 3. Добавлять несуществующий slug или повторять статью ради кратности нельзя. + +Каждая тройка — один исследовательский и редакционный спринт, а не три варианта одного текста. В серии: + +- `practice` даёт воспроизводимый порядок действий; +- `mechanism` объясняет причинную модель и границы состояния; +- `field` разбирает сбой, ограничение или решение в неоднозначном случае. + +В неполных сериях каждый материал обязан сам содержать практический пример и порядок проверки: отдельной `practice`-статьи там нет. + +## Количество статей по годам и месяцам + +| Год | Янв | Фев | Мар | Апр | Май | Июн | Июл | Авг | Сен | Окт | Ноя | Дек | Итого | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 2018 | 0* | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 2 | 3 | 3 | 32 | +| 2019 | 2 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 35 | +| 2020 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 36 | +| 2021 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 36 | +| 2022 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 36 | +| 2023 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 36 | +| 2024 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 36 | +| 2025 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 36 | +| 2026 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 36 | +| 2027 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 36 | +| **Всего** | **2** | **27** | **27** | **27** | **27** | **27** | **27** | **27** | **27** | **26** | **27** | **27** | **355** | + +\* В январе 2018 есть три статьи, но они исключены из очереди как уже прошедшие ревью. + +## Тематические линии и зрелость автора + +| Линия | Период | Статей | Уровень голоса | Что развивается | +| --- | --- | ---: | --- | --- | +| Legacy PHP, Bitrix и клиентская сборка | 2018 | 32 | М1 | Локальная диагностика, безопасная интеграция, первые границы между PHP и JavaScript | +| Frontend, API и данные | 2019 | 35 | М2 | T-shape: браузер, HTTP, типы, SQL, командные договорённости | +| Delivery, backend и наблюдаемость | 2020 | 36 | М3 | Эксплуатационная ответственность: релизы, логи, метрики, инциденты | +| Данные и распределённые системы | 2021 | 36 | М4 | Контракты, конкуренция, очереди, архитектурные компромиссы | +| Интерфейсы и пользовательский сценарий | 2022 | 36 | М5 | Рендеринг, доступность, сеть, UX и измеримые фронтенд-ограничения | +| Безопасность, качество и устойчивость | 2023 | 36 | М6 | Модель угроз, проверяемые контроли, контрактные и e2e-проверки | +| Модернизация и платформа | 2024 | 36 | М7 | Стоимость изменений, границы модулей, доставка, поддержка нескольких команд | +| AI-инструменты и инженерная коммуникация | 2025 | 36 | М8 | Оценка рисков, приватность, автоматизация, исследовательская дисциплина | +| Сквозная архитектура | 2026 | 36 | М9 | Платформенные интерфейсы, отказоустойчивость, техническое лидерство | +| Ретроспектива и наставничество | 2027 | 36 | М10 | Обобщения только через доказательства, исправление старых советов, передача практики | + +## Легенда для исполнения + +### Голос и целевой объём + +| Код | Голос автора | Обычный объём основного текста | +| --- | --- | --- | +| М1 | Практик 2018 года: спокойный разбор одной ошибки, короткие выводы, без архитектурных деклараций | 5–8 тыс. знаков | +| М2 | Web-инженер 2019 года: связывает браузер, API и данные, сравнивает два варианта | 6–9 тыс. знаков | +| М3 | Ответственный backend/ops-инженер: работает через наблюдения, измерения и отказные сценарии | 7–10 тыс. знаков | +| М4 | Системный инженер: называет инварианты, контракты и цену компромисса | 8–11 тыс. знаков | +| М5 | Frontend-системщик: начинает с пользовательского сценария и проверяет его в браузере | 7–10 тыс. знаков | +| М6 | Инженер надёжности и безопасности: формулирует угрозу, контроль и доказательство работы контроля | 8–11 тыс. знаков | +| М7 | Техлид платформы: объясняет стоимость, порядок миграции и влияние на команды | 8–12 тыс. знаков | +| М8 | Наставник-исследователь: отделяет измеренное от предположения, делает вывод воспроизводимым | 8–12 тыс. знаков | +| М9 | Архитектор-практик: связывает интерфейсы команд, данные, эксплуатацию и решение | 9–13 тыс. знаков | +| М10 | Рефлексивный эксперт: уточняет границы опыта, исправляет себя и оставляет метод | 9–15 тыс. знаков | + +### Типы первичных источников + +| Тип | Что именно собирать | +| --- | --- | +| Официальная документация | Документация вендора, спецификация, RFC, release notes или исходный код проекта-владельца | +| Воспроизводимое наблюдение | Минимальный стенд, трасса, лог, дамп конфигурации, тест, benchmark или артефакт CI | +| Артефакт проекта | Контракт, ADR, схема, история изменений, конфигурация, метрика, постмортем или тестовый набор с понятным происхождением | +| Официальное уведомление | Security advisory, CVE-выпуск, лицензия, policy/DPA, прайс-лист или условия платформы | + +Во всех партиях ниже указана пара разных типов. До публикации конкретные ссылки и версии должны попасть в статью, а не остаться в рабочей заметке. + +### Риски фактической устарелости + +| Код | Риск и обязательная проверка | +| --- | --- | +| Р-В | Версия продукта, API, фреймворка или браузера: назвать версию и перепроверить перед выпуском | +| Р-Б | Поведение браузера, ОС или окружения: проверить минимум в целевых средах | +| Р-О | Нагрузка, метрика или эксплуатационный эффект: приложить условия замера и не обобщать результат | +| Р-З | Безопасность: сверить актуальные advisory, алгоритмы, заголовки и настройки на дату публикации | +| Р-К | Командные, продуктовые или интервью-выводы: обезличить данные и отделить контекст команды от общего правила | +| Р-Ф | Дата статьи ещё не наступила на момент подготовки очереди: не выдавать прогноз или запланированную практику за наблюдавшийся факт | + +## Последовательность партий + +### 2018 — локальная практика PHP, Bitrix и старого frontend + +- **П01 · 2018-02 · Диагностика PHP-интеграции** + - **Slug:** `editorial-2018-02-practice-php-diagnostics`, `editorial-2018-02-mechanism-php-diagnostics`, `editorial-2018-02-field-php-diagnostics`. + - **Техническая проблема:** внешний вызов возвращает пустой результат, а ошибка теряется между обработчиком, логом и HTTP-ответом; развести проверку входа, захват исключения и отображение ошибки. + - **Источники:** официальная документация PHP по ошибкам и исключениям + минимальный падающий стенд с логом запроса. + - **Визуал:** схема распространения ошибки и фрагмент аннотированного лога. + - **Голос / объём:** М1, 5,5–7 тыс. знаков. + - **Зависимости и риск:** опирается на уже отревьюированный январский разбор Bitrix; Р-В. + +- **П02 · 2018-03 · Безопасная загрузка файлов** + - **Slug:** `editorial-2018-03-practice-safe-uploads`, `editorial-2018-03-mechanism-safe-uploads`, `editorial-2018-03-field-safe-uploads`. + - **Техническая проблема:** расширение, MIME-тип, временный файл и конечный путь говорят разное; показать, где проверять файл до перемещения и как не превратить загрузку в запись произвольного файла. + - **Источники:** официальная документация PHP по `$_FILES` и файловым операциям + контролируемые multipart-фикстуры с ошибочными файлами. + - **Визуал:** границы доверия загрузки и таблица «сигнал → проверка → действие». + - **Голос / объём:** М1, 6–8 тыс. знаков. + - **Зависимости и риск:** П01; Р-В, Р-З. + +- **П03 · 2018-04 · Символьный код и URL Bitrix** + - **Slug:** `editorial-2018-04-practice-bitrix-slugs`, `editorial-2018-04-mechanism-bitrix-slugs`, `editorial-2018-04-field-bitrix-slugs`. + - **Техническая проблема:** два элемента получают одинаковый URL, транслит меняет ожидаемый код, а проверка уникальности происходит поздно; разделить генерацию, нормализацию и запись. + - **Источники:** официальная документация Bitrix по транслитерации и инфоблокам + интеграционная фикстура с коллизиями символьных кодов. + - **Визуал:** путь «имя → нормализация → проверка → URL» и таблица коллизий. + - **Голос / объём:** М1, 6–8 тыс. знаков. + - **Зависимости и риск:** январские Bitrix-статьи; Р-В. + +- **П04 · 2018-05 · Старый jQuery без двойных обработчиков** + - **Slug:** `editorial-2018-05-practice-legacy-jquery`, `editorial-2018-05-mechanism-legacy-jquery`, `editorial-2018-05-field-legacy-jquery`. + - **Техническая проблема:** повторная инициализация вешает несколько обработчиков, глобальное состояние расходится с DOM; показать проверяемый жизненный цикл виджета. + - **Источники:** официальная документация jQuery по событиям и data API + браузерная трасса обработчиков на минимальной странице. + - **Визуал:** последовательность DOM-событий и дифф «до / после». + - **Голос / объём:** М1, 6–8 тыс. знаков. + - **Зависимости и риск:** независимая партия; Р-В, Р-Б. + +- **П05 · 2018-06 · Точки входа Webpack и циклические зависимости** + - **Slug:** `editorial-2018-06-practice-webpack-entry`, `editorial-2018-06-mechanism-webpack-entry`, `editorial-2018-06-field-webpack-entry`. + - **Техническая проблема:** код попадает в лишний bundle или модуль приходит `undefined` из-за цикла; разобрать граф импорта, entry и общий chunk на реальном `stats.json`. + - **Источники:** официальная документация Webpack + артефакт сборки `stats.json` и лог минимальной сборки. + - **Визуал:** граф зависимостей и снимок анализа bundle. + - **Голос / объём:** М1, 6–8 тыс. знаков. + - **Зависимости и риск:** П04; Р-В. + +- **П06 · 2018-07 · Таймауты веб-интеграции** + - **Slug:** `editorial-2018-07-practice-http-timeouts`, `editorial-2018-07-mechanism-http-timeouts`, `editorial-2018-07-field-http-timeouts`. + - **Техническая проблема:** connect-, read- и общий таймаут смешаны, повторный запрос усиливает сбой; разделить бюджеты времени и правила повторов. + - **Источники:** документация HTTP-клиента и соответствующая спецификация HTTP + трасса `curl`/тестового сервера с управляемой задержкой. + - **Визуал:** временная шкала запроса и матрица таймаутов. + - **Голос / объём:** М1, 6–8 тыс. знаков. + - **Зависимости и риск:** П01; Р-В, Р-О. + +- **П07 · 2018-08 · TLS и CA bundle в PHP** + - **Slug:** `editorial-2018-08-practice-tls-ca`, `editorial-2018-08-mechanism-tls-ca`, `editorial-2018-08-field-tls-ca`. + - **Техническая проблема:** проверка цепочки сертификатов падает, и разработчик отключает verification вместо исправления trust store; провести диагностику цепочки и настроек клиента. + - **Источники:** официальная документация OpenSSL/cURL + вывод проверки сертификатной цепочки на тестовом хосте. + - **Визуал:** схема цепочки доверия и таблица диагностических команд. + - **Голос / объём:** М1, 6–8 тыс. знаков. + - **Зависимости и риск:** П06; Р-В, Р-З. + +- **П08 · 2018-09 · Воспроизводимое окружение Windows** + - **Slug:** `editorial-2018-09-practice-windows-dev-env`, `editorial-2018-09-mechanism-windows-dev-env`, `editorial-2018-09-field-windows-dev-env`. + - **Техническая проблема:** один и тот же проект запускается по-разному из-за PATH, кодировок, версий бинарников и локальных настроек; зафиксировать минимальный снимок окружения. + - **Источники:** официальная документация Windows и инструментов среды + сохранённый вывод версий, переменных и шага воспроизведения. + - **Визуал:** слои окружения и таблица «машина A / машина B». + - **Голос / объём:** М1, 5–7 тыс. знаков. + - **Зависимости и риск:** независимая партия; Р-В, Р-Б. + +- **П09 · мост 2018-10 → 2019-01 · Legacy-интеграция формы и клиентского bundle** + - **Slug:** `editorial-2018-10-mechanism-image-workflow`, `editorial-2018-10-field-image-workflow`, `editorial-2019-01-mechanism-jquery-webpack`. + - **Техническая проблема:** форма с изображением и legacy-плагин пересекают серверное состояние, DOM и модульную сборку; отдельно показать владение файлом, порядок загрузки скриптов и сбой production-сборки. + - **Источники:** официальная документация Bitrix/jQuery/Webpack + браузерная и build-трасса минимального примера. + - **Визуал:** карта состояний формы и временная шкала загрузки ассетов. + - **Голос / объём:** М1 для статей 2018 года, М2 для статьи 2019 года; по 6–8 тыс. знаков. + - **Зависимости и риск:** П04, П05, январские Bitrix-статьи; Р-В, Р-Б. + +- **О-01 · 2019-01 · Полевая статья о jQuery в Webpack** + - **Slug:** `editorial-2019-01-field-jquery-webpack`. + - **Техническая проблема:** плагин работает в development, но ломается после production-сборки из-за глобальной переменной, порядка исполнения или дублированного пакета; дать один проверяемый сценарий без расширения серии несуществующим slug. + - **Источники:** официальная документация Webpack и jQuery + production-артефакт, source map и браузерная network-трасса. + - **Визуал:** порядок загрузки модулей с отмеченной точкой потери глобального объекта. + - **Голос / объём:** М2, 6–8 тыс. знаков. + - **Зависимости и риск:** П04, П05, П09; Р-В, Р-Б. + +- **П10 · 2018-11 · Интеграционные проверки PHP** + - **Slug:** `editorial-2018-11-practice-php-integration-tests`, `editorial-2018-11-mechanism-php-integration-tests`, `editorial-2018-11-field-php-integration-tests`. + - **Техническая проблема:** unit-тест зелёный, но запись в БД, HTTP-клиент или конфигурация ломают сценарий; определить границу интеграционного теста и управляемые внешние зависимости. + - **Источники:** официальная документация тестового фреймворка/PHP + контейнерная тестовая БД и трасса вызовов. + - **Визуал:** последовательность сценария и таблица уровней проверок. + - **Голос / объём:** М1, 7–9 тыс. знаков. + - **Зависимости и риск:** П01, П06; Р-В, Р-О. + +- **П11 · 2018-12 · Рефакторинг Bitrix без большого переписывания** + - **Slug:** `editorial-2018-12-practice-legacy-refactoring`, `editorial-2018-12-mechanism-legacy-refactoring`, `editorial-2018-12-field-legacy-refactoring`. + - **Техническая проблема:** изменение legacy-кода сразу затрагивает шаблон, инфоблок и интеграцию; найти шов, поставить проверку и заменить один участок без обещания «переписать всё». + - **Источники:** официальная документация Bitrix/PHP + история изменений, тестовый контур и карта зависимостей конкретного модуля. + - **Визуал:** карта швов legacy-модуля и таблица безопасных шагов. + - **Голос / объём:** М1, 7–9 тыс. знаков. + - **Зависимости и риск:** П01–П10 и январская серия 2018; Р-В, Р-К. + +### 2019 — browser, API, типы и границы данных + +- **П12 · 2019-02 · ES-модули в браузере** + - **Slug:** `editorial-2019-02-practice-es-modules`, `editorial-2019-02-mechanism-es-modules`, `editorial-2019-02-field-es-modules`. + - **Техническая проблема:** модуль грузится не в том порядке, путь резолвится иначе, чем в сборщике, или скрипт выполняется дважды; отделить нативный модульный сценарий от bundler-сценария. + - **Источники:** спецификация ECMAScript/официальная документация браузера + network-трасса и минимальный граф модулей. + - **Визуал:** граф импорта и последовательность загрузки `module`. + - **Голос / объём:** М2, 6–8 тыс. знаков. + - **Зависимости и риск:** П05, П09, О-01; Р-В, Р-Б. + +- **П13 · 2019-03 · Event loop и асинхронность** + - **Slug:** `editorial-2019-03-practice-event-loop`, `editorial-2019-03-mechanism-event-loop`, `editorial-2019-03-field-event-loop`. + - **Техническая проблема:** интерфейс зависает или порядок `Promise`-колбэков неожиданен; развести task, microtask и долгую синхронную работу на измеримом примере. + - **Источники:** спецификации ECMAScript/HTML + Chrome Performance trace минимального сценария. + - **Визуал:** временная шкала event loop и таблица порядка выполнения. + - **Голос / объём:** М2, 7–9 тыс. знаков. + - **Зависимости и риск:** П12; Р-В, Р-Б. + +- **П14 · 2019-04 · Валидация форм** + - **Slug:** `editorial-2019-04-practice-forms-validation`, `editorial-2019-04-mechanism-forms-validation`, `editorial-2019-04-field-forms-validation`. + - **Техническая проблема:** правила в браузере и на сервере расходятся, асинхронный ответ перезаписывает новое состояние, ошибка не связана с полем; описать конечный автомат формы. + - **Источники:** спецификация HTML/ARIA + фикстура формы с сетевой задержкой и accessibility tree. + - **Визуал:** автомат состояний формы и матрица ошибок. + - **Голос / объём:** М2, 7–9 тыс. знаков. + - **Зависимости и риск:** П12, П13; Р-Б, Р-В. + +- **П15 · 2019-05 · Кеширование HTTP-ответов** + - **Slug:** `editorial-2019-05-practice-http-caching`, `editorial-2019-05-mechanism-http-caching`, `editorial-2019-05-field-http-caching`. + - **Техническая проблема:** пользователь видит старые данные из-за неверных `Cache-Control`, `Vary` или ключа CDN; показать путь решения от заголовка до повторного запроса. + - **Источники:** актуальная спецификация HTTP caching + заголовки и трасса браузера/CDN для контролируемого ответа. + - **Визуал:** путь запроса через кеши и таблица условий попадания. + - **Голос / объём:** М2, 7–9 тыс. знаков. + - **Зависимости и риск:** П06; Р-В, Р-О. + +- **П16 · 2019-06 · Контракт REST API** + - **Slug:** `editorial-2019-06-practice-rest-api`, `editorial-2019-06-mechanism-rest-api`, `editorial-2019-06-field-rest-api`. + - **Техническая проблема:** клиент ломается не на URL, а на статусе, ошибке, пагинации или необязательном поле; превратить договорённость в проверяемый контракт. + - **Источники:** спецификация HTTP/OpenAPI + схема сервиса и результат контрактного теста. + - **Визуал:** последовательность запроса и таблица контракта ответа. + - **Голос / объём:** М2, 7–9 тыс. знаков. + - **Зависимости и риск:** П15; Р-В. + +- **П17 · 2019-07 · Индексы и планы SQL-запросов** + - **Slug:** `editorial-2019-07-practice-sql-indexes`, `editorial-2019-07-mechanism-sql-indexes`, `editorial-2019-07-field-sql-indexes`. + - **Техническая проблема:** добавленный индекс не ускоряет запрос из-за селективности, статистики или формы условия; читать `EXPLAIN ANALYZE` вместо угадывания. + - **Источники:** официальная документация PostgreSQL + воспроизводимый запрос, план и условия замера. + - **Визуал:** дерево плана и таблица «план / время / число строк». + - **Голос / объём:** М2, 8–10 тыс. знаков. + - **Зависимости и риск:** независимая база для следующих data-партий; Р-В, Р-О. + +- **П18 · 2019-08 · Производительность первой загрузки** + - **Slug:** `editorial-2019-08-practice-frontend-performance`, `editorial-2019-08-mechanism-frontend-performance`, `editorial-2019-08-field-frontend-performance`. + - **Техническая проблема:** страница «тяжёлая» без установленного виновника; отделить сетевую задержку, JavaScript, CSS и изображение на одном профиле загрузки. + - **Источники:** документация Web Vitals/браузера + Performance trace и размер bundle на одном стенде. + - **Визуал:** waterfall первой загрузки и график критического пути. + - **Голос / объём:** М2, 8–10 тыс. знаков. + - **Зависимости и риск:** П05, П13; Р-Б, Р-О. + +- **П19 · 2019-09 · Базовая доступность** + - **Slug:** `editorial-2019-09-practice-accessibility-basics`, `editorial-2019-09-mechanism-accessibility-basics`, `editorial-2019-09-field-accessibility-basics`. + - **Техническая проблема:** интерфейс выглядит рабочим, но фокус теряется, кнопка не имеет имени или ошибка не объявляется; проверить семантику в браузере, а не только по DOM. + - **Источники:** WCAG/спецификация HTML и ARIA + дерево доступности и ручной сценарий клавиатуры. + - **Визуал:** маршрут фокуса и таблица «визуальный элемент → семантика». + - **Голос / объём:** М2, 7–9 тыс. знаков. + - **Зависимости и риск:** П14; Р-Б. + +- **П20 · 2019-10 · Переход на TypeScript** + - **Slug:** `editorial-2019-10-practice-typescript-migration`, `editorial-2019-10-mechanism-typescript-migration`, `editorial-2019-10-field-typescript-migration`. + - **Техническая проблема:** миграция превращается в массовый `any` и блокирует доставку; выбрать границы типов, измерить охват и сохранить рабочую сборку. + - **Источники:** официальная документация TypeScript и release notes компилятора + журнал ошибок `tsc` и дифф миграции модуля. + - **Визуал:** график охвата типов и таблица границ `JS ↔ TS`. + - **Голос / объём:** М2, 8–10 тыс. знаков. + - **Зависимости и риск:** П12, П13; Р-В. + +- **П21 · 2019-11 · Воспроизводимая сборка** + - **Slug:** `editorial-2019-11-practice-reproducible-builds`, `editorial-2019-11-mechanism-reproducible-builds`, `editorial-2019-11-field-reproducible-builds`. + - **Техническая проблема:** один commit выдаёт разные артефакты из-за плавающей зависимости, среды или времени сборки; зафиксировать входы и проверить хеш результата. + - **Источники:** официальная документация пакетного менеджера/сборщика + lockfile, лог CI и сравнение хешей артефактов. + - **Визуал:** граф входов сборки и таблица различий артефактов. + - **Голос / объём:** М2, 7–9 тыс. знаков. + - **Зависимости и риск:** П05, П20; Р-В, Р-З. + +- **П22 · 2019-12 · Ответственность за код** + - **Slug:** `editorial-2019-12-practice-code-ownership`, `editorial-2019-12-mechanism-code-ownership`, `editorial-2019-12-field-code-ownership`. + - **Техническая проблема:** баг проходит через несколько модулей, а владелец решения неясен; отделить владение кодом, ревью и эксплуатационную ответственность. + - **Источники:** история репозитория/CODEOWNERS и правила review + обезличенные данные по issue и ревью. + - **Визуал:** карта владения и временная шкала передачи инцидента. + - **Голос / объём:** М2, 7–9 тыс. знаков. + - **Зависимости и риск:** П10, П21; Р-К. + +### 2020 — delivery, backend и наблюдаемость + +- **П23 · 2020-01 · Локальная разработка в Docker** + - **Slug:** `editorial-2020-01-practice-docker-local`, `editorial-2020-01-mechanism-docker-local`, `editorial-2020-01-field-docker-local`. + - **Техническая проблема:** контейнер маскирует разницу между локальными томами, сетью и переменными среды; собрать короткий сценарий, в котором «работает у меня» становится проверяемой конфигурацией. + - **Источники:** официальная документация Docker/Compose + `compose`-конфигурация и логи чистого запуска. + - **Визуал:** слои локального окружения и путь запроса через контейнеры. + - **Голос / объём:** М3, 7–9 тыс. знаков. + - **Зависимости и риск:** П08, П21; Р-В. + +- **П24 · 2020-02 · Настройки и секреты** + - **Slug:** `editorial-2020-02-practice-configs-secrets`, `editorial-2020-02-mechanism-configs-secrets`, `editorial-2020-02-field-configs-secrets`. + - **Техническая проблема:** секрет попадает в репозиторий, лог или образ, а конфигурация окружений расходится; показать путь секрета и ротацию вместо совета «не коммитить пароль». + - **Источники:** официальная документация хранилища секретов/VCS + инвентаризация переменных и безопасный отчёт сканера. + - **Визуал:** границы доверия секрета и таблица ротации. + - **Голос / объём:** М3, 7–9 тыс. знаков. + - **Зависимости и риск:** П23; Р-В, Р-З. + +- **П25 · 2020-03 · Минимальный CI/CD pipeline** + - **Slug:** `editorial-2020-03-practice-ci-pipeline`, `editorial-2020-03-mechanism-ci-pipeline`, `editorial-2020-03-field-ci-pipeline`. + - **Техническая проблема:** pipeline публикует неподтверждённый артефакт или повторяет дорогую работу; определить входы, проверки, артефакт и точку остановки. + - **Источники:** официальная документация выбранного CI + конфигурация pipeline, durations и артефакты одного запуска. + - **Визуал:** временная шкала pipeline и матрица «сбой → блокирует / не блокирует». + - **Голос / объём:** М3, 7–9 тыс. знаков. + - **Зависимости и риск:** П21, П23, П24; Р-В, Р-О. + +- **П26 · 2020-04 · Reverse proxy перед приложением** + - **Slug:** `editorial-2020-04-practice-reverse-proxy`, `editorial-2020-04-mechanism-reverse-proxy`, `editorial-2020-04-field-reverse-proxy`. + - **Техническая проблема:** proxy меняет IP клиента, заголовки, таймауты или кеширование, а приложение диагностирует не тот слой; разложить запрос по hop-ам. + - **Источники:** официальная документация Nginx/прокси + `curl`-, access- и upstream-логи тестового маршрута. + - **Визуал:** последовательность «клиент → proxy → приложение» и таблица заголовков. + - **Голос / объём:** М3, 7–9 тыс. знаков. + - **Зависимости и риск:** П06, П15, П23; Р-В, Р-О. + +- **П27 · 2020-05 · Фоновые задачи** + - **Slug:** `editorial-2020-05-practice-background-jobs`, `editorial-2020-05-mechanism-background-jobs`, `editorial-2020-05-field-background-jobs`. + - **Техническая проблема:** задача теряется между записью, очередью и worker-ом либо выполняется повторно; зафиксировать состояние, ack и обработку poison message. + - **Источники:** официальная документация брокера/worker-библиотеки + тестовый запуск с повтором и журналом состояний. + - **Визуал:** автомат жизненного цикла задачи и таблица retry-политики. + - **Голос / объём:** М3, 7–10 тыс. знаков. + - **Зависимости и риск:** П23, П25; Р-В, Р-О. + +- **П28 · 2020-06 · Повторы запросов и идемпотентность** + - **Slug:** `editorial-2020-06-practice-retry-idempotency`, `editorial-2020-06-mechanism-retry-idempotency`, `editorial-2020-06-field-retry-idempotency`. + - **Техническая проблема:** таймаут скрывает успешную операцию, повтор создаёт второй платёж или запись; связать ключ идемпотентности, хранилище результата и временной бюджет. + - **Источники:** спецификация HTTP и документация клиента + трасса «ответ потерян, операция выполнена» на стенде. + - **Визуал:** временная шкала первого и повторного запроса. + - **Голос / объём:** М3, 8–10 тыс. знаков. + - **Зависимости и риск:** П06, П16, П27; Р-В, Р-О. + +- **П29 · 2020-07 · Структурированные логи** + - **Slug:** `editorial-2020-07-practice-structured-logs`, `editorial-2020-07-mechanism-structured-logs`, `editorial-2020-07-field-structured-logs`. + - **Техническая проблема:** строки логов нельзя связать в один запрос, а полезный контекст появляется после инцидента; определить обязательные поля и момент их добавления. + - **Источники:** схема логирования проекта/официальная документация выбранного логгера + запрос к журналу по одному trace/correlation ID. + - **Визуал:** схема события лога и путь корреляции. + - **Голос / объём:** М3, 7–9 тыс. знаков. + - **Зависимости и риск:** П27, П28; Р-В, Р-К. + +- **П30 · 2020-08 · Метрики приложения** + - **Slug:** `editorial-2020-08-practice-metrics-basics`, `editorial-2020-08-mechanism-metrics-basics`, `editorial-2020-08-field-metrics-basics`. + - **Техническая проблема:** график показывает число запросов, но не объясняет деградацию; выбрать signal, label и порог, которые отвечают на конкретный операционный вопрос. + - **Источники:** официальная документация Prometheus/экспортера + сырые метрики, запрос и результат контролируемой нагрузки. + - **Визуал:** график сигнала во времени и таблица cardinality-ограничений. + - **Голос / объём:** М3, 7–10 тыс. знаков. + - **Зависимости и риск:** П29; Р-В, Р-О. + +- **П31 · 2020-09 · Трассировка запроса** + - **Slug:** `editorial-2020-09-practice-tracing-basics`, `editorial-2020-09-mechanism-tracing-basics`, `editorial-2020-09-field-tracing-basics`. + - **Техническая проблема:** задержка распределена между сервисами, но лог не показывает критический путь; провести trace context через границы и прочитать один waterfall. + - **Источники:** спецификация OpenTelemetry + экспорт одного trace и конфигурация collector-а. + - **Визуал:** waterfall span-ов и схема распространения trace context. + - **Голос / объём:** М3, 8–10 тыс. знаков. + - **Зависимости и риск:** П29, П30; Р-В, Р-О. + +- **П32 · 2020-10 · Резервное копирование и восстановление** + - **Slug:** `editorial-2020-10-practice-backup-recovery`, `editorial-2020-10-mechanism-backup-recovery`, `editorial-2020-10-field-backup-recovery`. + - **Техническая проблема:** backup существует, но восстановление не проверено, занимает больше RTO или пропускает зависимые данные; документировать drill, а не команду создания архива. + - **Источники:** официальная документация СУБД/хранилища + артефакты измеренного restore drill. + - **Визуал:** временная шкала восстановления и таблица RPO/RTO. + - **Голос / объём:** М3, 8–10 тыс. знаков. + - **Зависимости и риск:** П17, П25, П27; Р-В, Р-О. + +- **П33 · 2020-11 · Минимальная защита веб-приложения** + - **Slug:** `editorial-2020-11-practice-security-baseline`, `editorial-2020-11-mechanism-security-baseline`, `editorial-2020-11-field-security-baseline`. + - **Техническая проблема:** набор заголовков и проверок включён без связи с поверхностью атаки; начать с активов, входов и одного проверяемого контроля. + - **Источники:** официальная документация платформы/веб-сервера и актуальная OWASP ASVS + результаты разрешённого HTTP-проверочного стенда. + - **Визуал:** матрица «актив → угроза → контроль → доказательство». + - **Голос / объём:** М3, 8–10 тыс. знаков. + - **Зависимости и риск:** П24, П26; Р-В, Р-З. + +- **П34 · 2020-12 · Разбор инцидента** + - **Slug:** `editorial-2020-12-practice-incident-review`, `editorial-2020-12-mechanism-incident-review`, `editorial-2020-12-field-incident-review`. + - **Техническая проблема:** команда чинит видимый симптом, но не связывает триггер, обнаружение, восстановление и профилактику; собрать фактологичный review без поиска виноватого. + - **Источники:** исходные логи/алерты/изменения инцидента + runbook и конфигурация мониторинга на тот момент. + - **Визуал:** временная шкала инцидента и таблица решений с владельцами. + - **Голос / объём:** М3, 8–10 тыс. знаков. + - **Зависимости и риск:** П29–П33; Р-О, Р-К. + +### 2021 — данные, конкуренция и распределённые системы + +- **П35 · 2021-01 · Границы транзакции PostgreSQL** + - **Slug:** `editorial-2021-01-practice-transactions`, `editorial-2021-01-mechanism-transactions`, `editorial-2021-01-field-transactions`. + - **Техническая проблема:** две операции читают одно состояние и записывают несовместимый результат; показать изоляцию, блокировку и границу транзакции на конкурентной фикстуре. + - **Источники:** официальная документация PostgreSQL + пара параллельных транзакций с журналом блокировок. + - **Визуал:** временная шкала транзакций и таблица уровней изоляции. + - **Голос / объём:** М4, 8–10 тыс. знаков. + - **Зависимости и риск:** П17; Р-В, Р-О. + +- **П36 · 2021-02 · Инвалидация кеша** + - **Slug:** `editorial-2021-02-practice-cache-invalidation`, `editorial-2021-02-mechanism-cache-invalidation`, `editorial-2021-02-field-cache-invalidation`. + - **Техническая проблема:** источник уже обновлён, а пользователь получает старую проекцию; определить владельца ключа, событие инвалидирования и проверку попадания. + - **Источники:** официальная документация cache-store/фреймворка + трасса ключа до и после изменения данных. + - **Визуал:** жизненный цикл ключа и матрица консистентности. + - **Голос / объём:** М4, 8–10 тыс. знаков. + - **Зависимости и риск:** П15, П30, П35; Р-В, Р-О. + +- **П37 · 2021-03 · Очереди задач** + - **Slug:** `editorial-2021-03-practice-queues`, `editorial-2021-03-mechanism-queues`, `editorial-2021-03-field-queues`. + - **Техническая проблема:** порядок, дубли и poison message не укладываются в «поставили очередь»; определить delivery guarantee, retry и ручной маршрут ошибки. + - **Источники:** официальная документация брокера + тестовый harness с дубликатом, задержкой и ошибкой обработчика. + - **Визуал:** автомат сообщения и таблица гарантий доставки. + - **Голос / объём:** М4, 8–10 тыс. знаков. + - **Зависимости и риск:** П27, П28; Р-В, Р-О. + +- **П38 · 2021-04 · Согласованность данных между сервисами** + - **Slug:** `editorial-2021-04-practice-data-consistency`, `editorial-2021-04-mechanism-data-consistency`, `editorial-2021-04-field-data-consistency`. + - **Техническая проблема:** два сервиса считают один заказ в разных состояниях; задать инвариант, компенсирующее действие и границу eventual consistency. + - **Источники:** ADR/контракт данных проекта + интеграционная трасса несогласованного сценария. + - **Визуал:** state machine с компенсирующим действием. + - **Голос / объём:** М4, 9–11 тыс. знаков. + - **Зависимости и риск:** П35–П37; Р-О, Р-К. + +- **П39 · 2021-05 · Блокировки и конкуренция** + - **Slug:** `editorial-2021-05-practice-distributed-locks`, `editorial-2021-05-mechanism-distributed-locks`, `editorial-2021-05-field-distributed-locks`. + - **Техническая проблема:** lease истекает, владелец останавливается, а второй worker выполняет критическую секцию; не выдавать lock за универсальную защиту. + - **Источники:** официальная документация lock-provider-а + конкурентный тест с паузой владельца и логом lease. + - **Визуал:** временная шкала захвата, истечения и fence token. + - **Голос / объём:** М4, 8–11 тыс. знаков. + - **Зависимости и риск:** П35, П37, П38; Р-В, Р-О. + +- **П40 · 2021-06 · Событийная интеграция** + - **Slug:** `editorial-2021-06-practice-event-driven`, `editorial-2021-06-mechanism-event-driven`, `editorial-2021-06-field-event-driven`. + - **Техническая проблема:** событие приходит дважды, схема меняется без миграции или consumer отстаёт; зафиксировать envelope, совместимость и replay. + - **Источники:** спецификация CloudEvents/документация брокера + запись события и результат replay-теста. + - **Визуал:** топология producer–broker–consumer и таблица совместимости схем. + - **Голос / объём:** М4, 8–11 тыс. знаков. + - **Зависимости и риск:** П37, П38; Р-В, Р-О. + +- **П41 · 2021-07 · Индексация и поиск** + - **Slug:** `editorial-2021-07-practice-search-indexing`, `editorial-2021-07-mechanism-search-indexing`, `editorial-2021-07-field-search-indexing`. + - **Техническая проблема:** документ сохранён, но не находится или выдача устарела; пройти путь ingest, индексирования, refresh и запроса. + - **Источники:** официальная документация поискового движка + фикстура документа, индексный лог и запрос выдачи. + - **Визуал:** путь «запись → индекс → запрос» и таблица задержек. + - **Голос / объём:** М4, 8–10 тыс. знаков. + - **Зависимости и риск:** П36, П40; Р-В, Р-О. + +- **П42 · 2021-08 · Контракт хранилища** + - **Slug:** `editorial-2021-08-practice-storage-contracts`, `editorial-2021-08-mechanism-storage-contracts`, `editorial-2021-08-field-storage-contracts`. + - **Техническая проблема:** потребитель предполагает тип, nullable-поле или порядок, которые хранилище не гарантирует; описать совместимость чтения и записи. + - **Источники:** схема/документация хранилища + миграционный тест и набор совместимых данных. + - **Визуал:** матрица producer/consumer-совместимости. + - **Голос / объём:** М4, 8–10 тыс. знаков. + - **Зависимости и риск:** П38, П41; Р-В. + +- **П43 · 2021-09 · Версионирование API** + - **Slug:** `editorial-2021-09-practice-api-versioning`, `editorial-2021-09-mechanism-api-versioning`, `editorial-2021-09-field-api-versioning`. + - **Техническая проблема:** изменение поля или семантики ломает старый клиент; выбрать способ эволюции, объявить срок и подтвердить его contract test-ом. + - **Источники:** OpenAPI/официальная спецификация API + клиентская фикстура и результат совместимого contract test-а. + - **Визуал:** маршрут версий и таблица совместимости клиента. + - **Голос / объём:** М4, 8–10 тыс. знаков. + - **Зависимости и риск:** П16, П40, П42; Р-В. + +- **П44 · 2021-10 · Runtime D в сервисе** + - **Slug:** `editorial-2021-10-practice-d-runtime-service`, `editorial-2021-10-mechanism-d-runtime-service`, `editorial-2021-10-field-d-runtime-service`. + - **Техническая проблема:** ожидания от PHP/JavaScript переносят на runtime D, а стоимость GC, I/O или FFI оказывается другой; ограничить вопрос одним сервисным запросом и измерением. + - **Источники:** официальная документация языка D/runtime + исходник benchmark-а и профиль запуска. + - **Визуал:** трасса обработки запроса и таблица измерений. + - **Голос / объём:** М4, 8–10 тыс. знаков. + - **Зависимости и риск:** линия 2017 D, П17; Р-В, Р-О. + +- **П45 · 2021-11 · Нагрузочное тестирование** + - **Slug:** `editorial-2021-11-practice-load-testing`, `editorial-2021-11-mechanism-load-testing`, `editorial-2021-11-field-load-testing`. + - **Техническая проблема:** тест создаёт «много запросов», но не воспроизводит профиль пользователей и скрывает узкое место; зафиксировать сценарий, границы среды и критерий остановки. + - **Источники:** официальная документация load-test инструмента + сценарий, конфигурация среды и сырые результаты прогона. + - **Визуал:** кривая нагрузки с latency/error rate и таблица профиля. + - **Голос / объём:** М4, 8–11 тыс. знаков. + - **Зависимости и риск:** П30, П31, П44; Р-В, Р-О. + +- **П46 · 2021-12 · Архитектурный разбор решения** + - **Slug:** `editorial-2021-12-practice-architecture-review`, `editorial-2021-12-mechanism-architecture-review`, `editorial-2021-12-field-architecture-review`. + - **Техническая проблема:** решение выбирают по знакомому инструменту, а инварианты, стоимость и отказные режимы остаются неявными; провести review как проверку гипотез. + - **Источники:** ADR/диаграмма и исходный код границы системы + метрики, тесты или результат рассмотренного решения. + - **Визуал:** context/container-схема и матрица решений. + - **Голос / объём:** М4, 9–11 тыс. знаков. + - **Зависимости и риск:** П35–П45; Р-О, Р-К. + +### 2022 — frontend-система и пользовательский сценарий + +- **П47 · 2022-01 · Граница SSR и CSR** + - **Slug:** `editorial-2022-01-practice-ssr-csr`, `editorial-2022-01-mechanism-ssr-csr`, `editorial-2022-01-field-ssr-csr`. + - **Техническая проблема:** SSR отдаёт одно состояние, hydration ожидает другое, а данные загружаются дважды; определить, кому принадлежит запрос и состояние на каждой фазе. + - **Источники:** официальная документация используемого framework-а + браузерная трасса SSR/hydration на минимальной странице. + - **Визуал:** жизненный цикл рендера и таблица владения состоянием. + - **Голос / объём:** М5, 7–9 тыс. знаков. + - **Зависимости и риск:** П18, П43; Р-В, Р-Б. + +- **П48 · 2022-02 · Бюджет производительности** + - **Slug:** `editorial-2022-02-practice-performance-budget`, `editorial-2022-02-mechanism-performance-budget`, `editorial-2022-02-field-performance-budget`. + - **Техническая проблема:** скорость деградирует по чуть-чуть, пока релиз не становится заметно медленным; превратить бюджет в проверку CI с понятным допуском. + - **Источники:** официальная документация Web Vitals/Lighthouse + результаты CI и условия замера одной страницы. + - **Визуал:** график бюджета и таблица допусков. + - **Голос / объём:** М5, 7–9 тыс. знаков. + - **Зависимости и риск:** П18, П47; Р-Б, Р-О. + +- **П49 · 2022-03 · Как браузер рисует страницу** + - **Slug:** `editorial-2022-03-practice-browser-rendering`, `editorial-2022-03-mechanism-browser-rendering`, `editorial-2022-03-field-browser-rendering`. + - **Техническая проблема:** «медленный рендер» смешивает style, layout, paint и JavaScript; связать конкретное изменение DOM с кадром в Performance panel. + - **Источники:** официальная документация браузерного движка + экспорт Performance trace с воспроизводимым jank. + - **Визуал:** frame timeline и схема render pipeline. + - **Голос / объём:** М5, 8–10 тыс. знаков. + - **Зависимости и риск:** П13, П18, П48; Р-Б. + +- **П50 · 2022-04 · Доступный интерфейс** + - **Slug:** `editorial-2022-04-practice-accessible-interface`, `editorial-2022-04-mechanism-accessible-interface`, `editorial-2022-04-field-accessible-interface`. + - **Техническая проблема:** компонент визуально готов, но недоступен с клавиатуры и не сообщает состояние ассистивной технологии; проверить семантику, фокус и объявление. + - **Источники:** WCAG/ARIA и официальная документация браузера + accessibility tree и сценарий ручной проверки. + - **Визуал:** последовательность фокуса и карта объявлений. + - **Голос / объём:** М5, 7–10 тыс. знаков. + - **Зависимости и риск:** П19, П49; Р-Б. + +- **П51 · 2022-05 · Маленькая дизайн-система** + - **Slug:** `editorial-2022-05-practice-design-system`, `editorial-2022-05-mechanism-design-system`, `editorial-2022-05-field-design-system`. + - **Техническая проблема:** одинаковые кнопки расходятся по токенам, состояниям и a11y; выбрать минимальный контракт компонента без фальшивой «универсальной системы». + - **Источники:** исходный код компонентной библиотеки/токенов + визуальные regression-фикстуры и usage-инвентарь. + - **Визуал:** поток токенов и таблица вариантов компонента. + - **Голос / объём:** М5, 7–10 тыс. знаков. + - **Зависимости и риск:** П50; Р-В, Р-К. + +- **П52 · 2022-06 · Ошибки формы** + - **Slug:** `editorial-2022-06-practice-form-errors`, `editorial-2022-06-mechanism-form-errors`, `editorial-2022-06-field-form-errors`. + - **Техническая проблема:** серверная ошибка приходит после исправления поля, сообщение не связано с причиной, а пользователь не понимает следующий шаг; зафиксировать состояние валидации и повторной отправки. + - **Источники:** HTML/ARIA-спецификация + запись сценария с искусственной сетевой задержкой и тестовый набор формы. + - **Визуал:** автомат формы и таблица приоритетов ошибок. + - **Голос / объём:** М5, 7–9 тыс. знаков. + - **Зависимости и риск:** П14, П50; Р-Б, Р-В. + +- **П53 · 2022-07 · Работа при плохой сети** + - **Slug:** `editorial-2022-07-practice-poor-network`, `editorial-2022-07-mechanism-poor-network`, `editorial-2022-07-field-poor-network`. + - **Техническая проблема:** интерфейс показывает успешное действие без ответа сервера, повторяет запрос или теряет черновик при offline; определить видимое состояние и правило восстановления. + - **Источники:** Fetch/Service Worker-документация + network-throttling trace и offline-фикстура. + - **Визуал:** диаграмма сетевых состояний и таблица retry/отката. + - **Голос / объём:** М5, 8–10 тыс. знаков. + - **Зависимости и риск:** П28, П52; Р-В, Р-Б. + +- **П54 · 2022-08 · Изображения и медиа** + - **Slug:** `editorial-2022-08-practice-media-performance`, `editorial-2022-08-mechanism-media-performance`, `editorial-2022-08-field-media-performance`. + - **Техническая проблема:** изображение увеличивает LCP, вызывает layout shift или грузится не по приоритету; сопоставить разметку, формат и реальную загрузку. + - **Источники:** официальная документация браузера/форматов изображений + trace и Web Vitals одного стенда. + - **Визуал:** waterfall медиа и график «до / после». + - **Голос / объём:** М5, 7–10 тыс. знаков. + - **Зависимости и риск:** П48, П53; Р-Б, Р-О. + +- **П55 · 2022-09 · Мобильный сценарий** + - **Slug:** `editorial-2022-09-practice-mobile-ux`, `editorial-2022-09-mechanism-mobile-ux`, `editorial-2022-09-field-mobile-ux`. + - **Техническая проблема:** десктопный поток требует точного hover, двух рук или широкого экрана; проверить один пользовательский путь на реальном viewport. + - **Источники:** документация браузерных viewport API + запись прохождения задачи и обезличенный сигнал продукта. + - **Визуал:** схема task flow на мобильном экране и таблица препятствий. + - **Голос / объём:** М5, 7–9 тыс. знаков. + - **Зависимости и риск:** П52–П54; Р-Б, Р-К. + +- **П56 · 2022-10 · Проверки пользовательского сценария** + - **Slug:** `editorial-2022-10-practice-ui-tests`, `editorial-2022-10-mechanism-ui-tests`, `editorial-2022-10-field-ui-tests`. + - **Техническая проблема:** e2e-тест ждёт произвольную секунду, не наблюдает нужное состояние и флакует; заменить ожидание времени наблюдаемым контрактом страницы. + - **Источники:** официальная документация e2e-инструмента + trace, video и статистика повторов одного теста. + - **Визуал:** временная шкала теста и матрица причин флака. + - **Голос / объём:** М5, 7–10 тыс. знаков. + - **Зависимости и риск:** П25, П52, П55; Р-В, Р-О. + +- **П57 · 2022-11 · Производительность Bitrix-страницы** + - **Slug:** `editorial-2022-11-practice-bitrix-performance`, `editorial-2022-11-mechanism-bitrix-performance`, `editorial-2022-11-field-bitrix-performance`. + - **Техническая проблема:** медленную страницу объясняют Bitrix целиком, хотя время уходит в запрос, компонентный кеш или шаблон; снять разложение запроса до оптимизации. + - **Источники:** официальная документация Bitrix по кешированию + профилировщик, SQL-trace и trace браузерной загрузки. + - **Визуал:** breakdown запроса и таблица узких мест. + - **Голос / объём:** М5, 8–10 тыс. знаков. + - **Зависимости и риск:** П36, П48, линия 2018 Bitrix; Р-В, Р-О. + +- **П58 · 2022-12 · Исследование пользовательской проблемы** + - **Slug:** `editorial-2022-12-practice-user-research`, `editorial-2022-12-mechanism-user-research`, `editorial-2022-12-field-user-research`. + - **Техническая проблема:** инженер улучшает интерфейс без подтверждённой проблемы; собрать наблюдение, отделить факт от интерпретации и выбрать следующий эксперимент. + - **Источники:** протокол исследования/согласованные исходные заметки + запись задачи или обезличенный продуктовый сигнал. + - **Визуал:** карта «наблюдение → гипотеза → решение» и таблица доказательств. + - **Голос / объём:** М5, 7–9 тыс. знаков. + - **Зависимости и риск:** П55, П56; Р-К. + +### 2023 — безопасность, качество и устойчивость + +- **П59 · 2023-01 · Модель угроз** + - **Slug:** `editorial-2023-01-practice-threat-model`, `editorial-2023-01-mechanism-threat-model`, `editorial-2023-01-field-threat-model`. + - **Техническая проблема:** контроль выбирают по чек-листу, не назвав актив, границу доверия и злоупотребление; построить небольшую модель угроз для одного потока данных. + - **Источники:** официальная методика threat modeling/стандарт безопасности + актуальная схема потока данных и список активов проекта. + - **Визуал:** DFD с trust boundary и таблица «угроза → контроль → доказательство». + - **Голос / объём:** М6, 8–10 тыс. знаков. + - **Зависимости и риск:** П33, П58; Р-З, Р-К. + +- **П60 · 2023-02 · Аутентификация и сессии** + - **Slug:** `editorial-2023-02-practice-sessions-auth`, `editorial-2023-02-mechanism-sessions-auth`, `editorial-2023-02-field-sessions-auth`. + - **Техническая проблема:** cookie-флаги, rotation и logout трактуются как отдельные настройки, из-за чего сессия живёт дольше ожидаемого; разобрать жизненный цикл одной сессии. + - **Источники:** официальная спецификация cookie/документация auth-платформы + браузерная storage/network-трасса тестовой сессии. + - **Визуал:** автомат сессии и таблица параметров cookie. + - **Голос / объём:** М6, 8–10 тыс. знаков. + - **Зависимости и риск:** П59; Р-В, Р-Б, Р-З. + +- **П61 · 2023-03 · CSRF и CORS** + - **Slug:** `editorial-2023-03-practice-csrf-cors`, `editorial-2023-03-mechanism-csrf-cors`, `editorial-2023-03-field-csrf-cors`. + - **Техническая проблема:** CORS принимают за защиту от CSRF или открывают origin с credentials; разделить модель браузера, серверную проверку и конкретный запрос. + - **Источники:** Fetch/CORS-спецификация + сетевой repro в двух origin с cookie и preflight. + - **Визуал:** карта origin, credentials и preflight. + - **Голос / объём:** М6, 8–10 тыс. знаков. + - **Зависимости и риск:** П60; Р-Б, Р-З. + +- **П62 · 2023-04 · Безопасность зависимостей** + - **Slug:** `editorial-2023-04-practice-dependency-security`, `editorial-2023-04-mechanism-dependency-security`, `editorial-2023-04-field-dependency-security`. + - **Техническая проблема:** уязвимость скрыта в транзитивной зависимости, а механическое обновление ломает runtime; соединить advisory, lockfile, достижимость и проверку обновления. + - **Источники:** официальные security advisory экосистемы + lockfile/SBOM и результат сканирования конкретной сборки. + - **Визуал:** дерево зависимости с зоной риска и матрица решения об обновлении. + - **Голос / объём:** М6, 8–11 тыс. знаков. + - **Зависимости и риск:** П21, П33, П59; Р-В, Р-З. + +- **П63 · 2023-05 · Статический анализ** + - **Slug:** `editorial-2023-05-practice-static-analysis`, `editorial-2023-05-mechanism-static-analysis`, `editorial-2023-05-field-static-analysis`. + - **Техническая проблема:** правило создаёт много шума или пропускает контекст, поэтому команда отключает анализатор; выбрать одну опасную категорию и измерить качество сигнала. + - **Источники:** официальная документация/исходный код правила анализатора + SARIF-отчёт и классификация находок из репозитория. + - **Визуал:** воронка triage и таблица «находка → решение». + - **Голос / объём:** М6, 8–10 тыс. знаков. + - **Зависимости и риск:** П56, П62; Р-В, Р-К. + +- **П64 · 2023-06 · Секреты и цепочка поставки** + - **Slug:** `editorial-2023-06-practice-secrets-supply-chain`, `editorial-2023-06-mechanism-secrets-supply-chain`, `editorial-2023-06-field-secrets-supply-chain`. + - **Техническая проблема:** секрет или неподписанный артефакт проходит через CI и образ, но аудит видит только финальный deploy; перечислить звенья и доказательства целостности. + - **Источники:** официальная документация build/attestation-платформы и SLSA + логи CI, SBOM и результаты безопасного сканирования артефакта. + - **Визуал:** границы цепочки поставки и таблица артефактов. + - **Голос / объём:** М6, 8–11 тыс. знаков. + - **Зависимости и риск:** П24, П25, П62; Р-В, Р-З. + +- **П65 · 2023-07 · Контрактные тесты API** + - **Slug:** `editorial-2023-07-practice-contract-tests`, `editorial-2023-07-mechanism-contract-tests`, `editorial-2023-07-field-contract-tests`. + - **Техническая проблема:** provider меняет семантику, consumer узнаёт это после релиза; зафиксировать пример контракта, совместимость и запуск с обеих сторон. + - **Источники:** OpenAPI/Pact-документация + contract-файл и результат provider verification. + - **Визуал:** путь контракта через consumer и provider, матрица совместимости. + - **Голос / объём:** М6, 8–10 тыс. знаков. + - **Зависимости и риск:** П43, П63; Р-В. + +- **П66 · 2023-08 · Стабильные e2e-тесты** + - **Slug:** `editorial-2023-08-practice-e2e-stability`, `editorial-2023-08-mechanism-e2e-stability`, `editorial-2023-08-field-e2e-stability`. + - **Техническая проблема:** retry превращает реальную ошибку в шум, а тест ждёт не ту готовность интерфейса; классифицировать флак по trace, а не повышать timeout. + - **Источники:** официальная документация e2e-инструмента + trace/video и история повторных запусков. + - **Визуал:** классификация флака и временная шкала ожиданий. + - **Голос / объём:** М6, 8–10 тыс. знаков. + - **Зависимости и риск:** П56, П65; Р-В, Р-О. + +- **П67 · 2023-09 · Логи, метрики и трассы** + - **Slug:** `editorial-2023-09-practice-telemetry-signals`, `editorial-2023-09-mechanism-telemetry-signals`, `editorial-2023-09-field-telemetry-signals`. + - **Техническая проблема:** три сигнала существуют раздельно, label взрывает cardinality, а причина запроса не связывается с ошибкой; провести один идентификатор через все представления. + - **Источники:** спецификация OpenTelemetry + согласованный набор trace, log и metric-query для одного сценария. + - **Визуал:** карта корреляции сигналов и таблица cardinality. + - **Голос / объём:** М6, 8–11 тыс. знаков. + - **Зависимости и риск:** П29–П31; Р-В, Р-О. + +- **П68 · 2023-10 · SLI и SLO** + - **Slug:** `editorial-2023-10-practice-sli-slo`, `editorial-2023-10-mechanism-sli-slo`, `editorial-2023-10-field-sli-slo`. + - **Техническая проблема:** alert смотрит на удобную метрику, а не на пользовательский результат; сформулировать SLI, SLO, окно и правило расходования error budget. + - **Источники:** конфигурация service objective и alert-правила + сырые данные метрик и история срабатываний. + - **Визуал:** график error budget и таблица соответствия SLI сценарию. + - **Голос / объём:** М6, 8–11 тыс. знаков. + - **Зависимости и риск:** П30, П45, П67; Р-О, Р-К. + +- **П69 · 2023-11 · Postmortem без поиска виноватого** + - **Slug:** `editorial-2023-11-practice-postmortem`, `editorial-2023-11-mechanism-postmortem`, `editorial-2023-11-field-postmortem`. + - **Техническая проблема:** ретроспектива заменяет доказательства оценками людей и не меняет систему; отделить факты, решения в моменте и профилактический эксперимент. + - **Источники:** исходные артефакты инцидента + история изменений, алертов и runbook-а. + - **Визуал:** timeline инцидента и таблица «сигнал → решение → действие». + - **Голос / объём:** М6, 8–10 тыс. знаков. + - **Зависимости и риск:** П34, П68; Р-О, Р-К. + +- **П70 · 2023-12 · Практический аудит веб-проекта** + - **Slug:** `editorial-2023-12-practice-security-audit`, `editorial-2023-12-mechanism-security-audit`, `editorial-2023-12-field-security-audit`. + - **Техническая проблема:** аудит превращается в общий список, не покрывающий реальные активы и пути данных; ограничить область, доказательства и порядок исправлений. + - **Источники:** официальное руководство по security testing + инвентарь активов и результаты разрешённых проверок стенда. + - **Визуал:** матрица покрытия аудита и карта активов. + - **Голос / объём:** М6, 9–11 тыс. знаков. + - **Зависимости и риск:** П59–П64, П67; Р-В, Р-З. + +### 2024 — модернизация, платформа и доставка + +- **П71 · 2024-01 · Модернизация legacy-системы** + - **Slug:** `editorial-2024-01-practice-legacy-modernization`, `editorial-2024-01-mechanism-legacy-modernization`, `editorial-2024-01-field-legacy-modernization`. + - **Техническая проблема:** переписывание теряет поведение и останавливает развитие; выбрать измеримый шов, правило совместимости и этап отката. + - **Источники:** исходный код/история legacy-модуля + официальная документация целевой платформы и результаты parity-теста. + - **Визуал:** strangler-карта и матрица совместимости. + - **Голос / объём:** М7, 9–11 тыс. знаков. + - **Зависимости и риск:** П11, П46; Р-В, Р-К. + +- **П72 · 2024-02 · Модульный монолит** + - **Slug:** `editorial-2024-02-practice-modular-monolith`, `editorial-2024-02-mechanism-modular-monolith`, `editorial-2024-02-field-modular-monolith`. + - **Техническая проблема:** модули являются только папками и обходят границы через общий слой; задать разрешённые зависимости и тестировать их. + - **Источники:** dependency graph/исходный код репозитория + ADR и тесты границ модулей. + - **Визуал:** матрица зависимостей и схема допустимых направлений. + - **Голос / объём:** М7, 8–11 тыс. знаков. + - **Зависимости и риск:** П46, П71; Р-К. + +- **П73 · 2024-03 · Границы пакетов** + - **Slug:** `editorial-2024-03-practice-package-boundaries`, `editorial-2024-03-mechanism-package-boundaries`, `editorial-2024-03-field-package-boundaries`. + - **Техническая проблема:** общая утилита превращается в неявную платформу, а пакет тянет чужую доменную модель; определить публичный API и запретные импорты. + - **Источники:** документация package manager-а/сборщика + import graph и CI-проверка границ. + - **Визуал:** граф пакетов и таблица API-границ. + - **Голос / объём:** М7, 8–10 тыс. знаков. + - **Зависимости и риск:** П72; Р-В, Р-К. + +- **П74 · 2024-04 · Безопасная миграция данных** + - **Slug:** `editorial-2024-04-practice-data-migrations`, `editorial-2024-04-mechanism-data-migrations`, `editorial-2024-04-field-data-migrations`. + - **Техническая проблема:** schema change выкатывается раньше кода, backfill перегружает БД, откат невозможен; применить expand–migrate–contract с измеренным rehearsal. + - **Источники:** официальная документация СУБД + миграция, rollback-план и журнал rehearsal-прогона. + - **Визуал:** временная шкала expand/contract и таблица совместимости версий. + - **Голос / объём:** М7, 9–12 тыс. знаков. + - **Зависимости и риск:** П35, П42, П72; Р-В, Р-О. + +- **П75 · 2024-05 · Шаблоны для команд** + - **Slug:** `editorial-2024-05-practice-platform-templates`, `editorial-2024-05-mechanism-platform-templates`, `editorial-2024-05-field-platform-templates`. + - **Техническая проблема:** шаблон ускоряет старт, но жёстко фиксирует неверный выбор и плодит fork-и; выбрать golden path, расширение и критерий отказа от шаблона. + - **Источники:** исходный код шаблона/usage-инвентарь + issue и интервью с командами-потребителями. + - **Визуал:** путь команды через шаблон и таблица escape hatch. + - **Голос / объём:** М7, 8–11 тыс. знаков. + - **Зависимости и риск:** П72, П73; Р-К, Р-В. + +- **П76 · 2024-06 · Управление контейнерной нагрузкой** + - **Slug:** `editorial-2024-06-practice-container-orchestration`, `editorial-2024-06-mechanism-container-orchestration`, `editorial-2024-06-field-container-orchestration`. + - **Техническая проблема:** resource limit, autoscaling и readiness не соответствуют профилю приложения; сопоставить манифест, загрузку и наблюдаемое поведение pod-а. + - **Источники:** официальная документация Kubernetes + manifests и метрики контролируемой нагрузки кластера. + - **Визуал:** жизненный цикл pod-а и график capacity. + - **Голос / объём:** М7, 9–12 тыс. знаков. + - **Зависимости и риск:** П23, П45; Р-В, Р-О. + +- **П77 · 2024-07 · Инженерия релиза** + - **Slug:** `editorial-2024-07-practice-release-engineering`, `editorial-2024-07-mechanism-release-engineering`, `editorial-2024-07-field-release-engineering`. + - **Техническая проблема:** версия, артефакт и миграция расходятся между стадиями; создать цепочку доказательств от commit-а до deploy-а и контролируемый rollback. + - **Источники:** документация CI/CD/deploy-платформы + журнал артефактов и событий поставки. + - **Визуал:** delivery chain и таблица точек rollback. + - **Голос / объём:** М7, 9–11 тыс. знаков. + - **Зависимости и риск:** П25, П74–П76; Р-В, Р-О. + +- **П78 · 2024-08 · Feature flags** + - **Slug:** `editorial-2024-08-practice-feature-flags`, `editorial-2024-08-mechanism-feature-flags`, `editorial-2024-08-field-feature-flags`. + - **Техническая проблема:** флаг скрывает долг, меняет поведение клиента или остаётся после rollout; определить владельца, аудиторию, срок и безопасное удаление. + - **Источники:** официальная документация feature-flag платформы + конфигурация флага и обезличенные данные exposure. + - **Визуал:** decision tree флага и временная шкала rollout. + - **Голос / объём:** М7, 8–11 тыс. знаков. + - **Зависимости и риск:** П77; Р-В, Р-К. + +- **П79 · 2024-09 · Запись инженерного решения** + - **Slug:** `editorial-2024-09-practice-adr-decisions`, `editorial-2024-09-mechanism-adr-decisions`, `editorial-2024-09-field-adr-decisions`. + - **Техническая проблема:** решение существует только в переписке, а через полгода никто не помнит условие и цену; оформить ADR как проверяемую гипотезу с датой пересмотра. + - **Источники:** история ADR и review-артефакты + исходный код/метрики, на которых основан выбор. + - **Визуал:** поток принятия решения и матрица альтернатив. + - **Голос / объём:** М7, 8–10 тыс. знаков. + - **Зависимости и риск:** П46, П77, П78; Р-К. + +- **П80 · 2024-10 · Ёмкость и стоимость** + - **Slug:** `editorial-2024-10-practice-capacity-cost`, `editorial-2024-10-mechanism-capacity-cost`, `editorial-2024-10-field-capacity-cost`. + - **Техническая проблема:** сервис проходит SLO, но стоимость растёт быстрее нагрузки; связать рабочую нагрузку, ресурс, цену и предел масштабирования. + - **Источники:** официальный прайс-лист/документация инфраструктуры + данные потребления и измерение нагрузки. + - **Визуал:** график «нагрузка → ресурс → стоимость» и таблица сценариев. + - **Голос / объём:** М7, 8–11 тыс. знаков. + - **Зависимости и риск:** П45, П76; Р-В, Р-О. + +- **П81 · 2024-11 · Удаление устаревшего** + - **Slug:** `editorial-2024-11-practice-deprecation`, `editorial-2024-11-mechanism-deprecation`, `editorial-2024-11-field-deprecation`. + - **Техническая проблема:** удаление API ломает скрытого потребителя, а старый путь живёт вечно; найти пользователей, сообщить срок, измерить миграцию и удалить доказуемо. + - **Источники:** code search/история использования + официальные правила версионирования и telemetry потребителей. + - **Визуал:** карта потребителей и timeline deprecation. + - **Голос / объём:** М7, 8–11 тыс. знаков. + - **Зависимости и риск:** П43, П73, П79; Р-В, Р-К. + +- **П82 · 2024-12 · Год сопровождения системы** + - **Slug:** `editorial-2024-12-practice-maintenance-retro`, `editorial-2024-12-mechanism-maintenance-retro`, `editorial-2024-12-field-maintenance-retro`. + - **Техническая проблема:** долг перечисляют без связи с риском, стоимостью и повторяемым сбоем; собрать maintenance-ретроспективу из исторических артефактов. + - **Источники:** issue/change history и ADR + метрики надёжности, стоимости или времени поддержки. + - **Визуал:** тепловая карта долг/риск и timeline изменений. + - **Голос / объём:** М7, 9–12 тыс. знаков. + - **Зависимости и риск:** П71–П81; Р-О, Р-К. + +### 2025 — AI-инструменты, внутренние продукты и исследовательская дисциплина + +- **П83 · 2025-01 · Помощник для написания кода** + - **Slug:** `editorial-2025-01-practice-ai-coding-assistant`, `editorial-2025-01-mechanism-ai-coding-assistant`, `editorial-2025-01-field-ai-coding-assistant`. + - **Техническая проблема:** сгенерированный код выглядит правдоподобно, но нарушает контракт, стиль или безопасность; ограничить задачу, подготовить контекст и сделать проверку обязательным шагом. + - **Источники:** официальная документация/model card поставщика инструмента + контролируемый набор задач, diff и результаты тестов. + - **Визуал:** граница «prompt → diff → review → test» и таблица типов ошибок. + - **Голос / объём:** М8, 8–10 тыс. знаков. + - **Зависимости и риск:** П63, П79; Р-В, Р-З. + +- **П84 · 2025-02 · Проверка сгенерированного кода** + - **Slug:** `editorial-2025-02-practice-ai-code-verification`, `editorial-2025-02-mechanism-ai-code-verification`, `editorial-2025-02-field-ai-code-verification`. + - **Техническая проблема:** тест проходит, но код всё ещё небезопасен, нечитабелен или не учитывает неявный контракт; построить несколько независимых доказательств вместо одной зелёной проверки. + - **Источники:** официальная документация AI-инструмента/его ограничений + результаты тестов, линтера, review и ручного воспроизведения. + - **Визуал:** воронка верификации и матрица «риск → способ обнаружения». + - **Голос / объём:** М8, 8–11 тыс. знаков. + - **Зависимости и риск:** П63, П83; Р-В, Р-З. + +- **П85 · 2025-03 · Поиск по инженерной базе знаний** + - **Slug:** `editorial-2025-03-practice-knowledge-retrieval`, `editorial-2025-03-mechanism-knowledge-retrieval`, `editorial-2025-03-field-knowledge-retrieval`. + - **Техническая проблема:** поиск возвращает устаревший или закрытый источник, а ответ не позволяет проверить цитату; связать индекс, права, дату документа и ссылку на первоисточник. + - **Источники:** официальная документация retrieval-платформы + инвентарь корпуса, журнал индексации и выборка ответов с источниками. + - **Визуал:** путь «запрос → retrieval → цитата» и таблица свежести источника. + - **Голос / объём:** М8, 8–11 тыс. знаков. + - **Зависимости и риск:** П75, П84; Р-В, Р-З, Р-К. + +- **П86 · 2025-04 · Данные и приватность в AI-инструментах** + - **Slug:** `editorial-2025-04-practice-ai-data-privacy`, `editorial-2025-04-mechanism-ai-data-privacy`, `editorial-2025-04-field-ai-data-privacy`. + - **Техническая проблема:** фрагмент кода или лога пересекает внешнюю границу без ясности о retention и обучении; сопоставить классификацию данных, policy и фактический egress. + - **Источники:** официальная policy/DPA поставщика + инвентарь потоков данных и журналы разрешённого egress. + - **Визуал:** trust boundary данных и таблица допустимости контекста. + - **Голос / объём:** М8, 8–11 тыс. знаков. + - **Зависимости и риск:** П24, П59, П83, П85; Р-В, Р-З, Р-К. + +- **П87 · 2025-05 · Автоматизация рутины** + - **Slug:** `editorial-2025-05-practice-engineering-automation`, `editorial-2025-05-mechanism-engineering-automation`, `editorial-2025-05-field-engineering-automation`. + - **Техническая проблема:** автоматизация масштабирует исключение и делает массовый неверный change; добавить preview, approval, audit log и откат в сам механизм. + - **Источники:** официальная документация API/оркестратора + dry-run, audit log и безопасный тест rollback. + - **Визуал:** автомат approval/rollback и таблица уровней полномочий. + - **Голос / объём:** М8, 8–11 тыс. знаков. + - **Зависимости и риск:** П25, П77, П84; Р-В, Р-К. + +- **П88 · 2025-06 · Удобство внутреннего инструмента** + - **Slug:** `editorial-2025-06-practice-developer-experience`, `editorial-2025-06-mechanism-developer-experience`, `editorial-2025-06-field-developer-experience`. + - **Техническая проблема:** внутренний инструмент формально работает, но создаёт очередь ожидания и обходные пути; измерить одну задачу пользователя от входа до результата. + - **Источники:** исходный код/документация внутреннего инструмента + наблюдение задачи, support-обращения или измерение времени ожидания. + - **Визуал:** пользовательский путь и гистограмма времени ожидания. + - **Голос / объём:** М8, 8–10 тыс. знаков. + - **Зависимости и риск:** П75, П87; Р-К. + +- **П89 · 2025-07 · Метрики продукта для инженера** + - **Slug:** `editorial-2025-07-practice-product-metrics`, `editorial-2025-07-mechanism-product-metrics`, `editorial-2025-07-field-product-metrics`. + - **Техническая проблема:** инженер улучшает latency или conversion без связи с решением пользователя и guardrail-метрикой; построить одну причинную цепочку, а не дашборд из всего. + - **Источники:** event schema/определение метрики + сырые события и запрос дашборда за фиксированный период. + - **Визуал:** funnel с guardrail и таблица интерпретации изменения. + - **Голос / объём:** М8, 8–11 тыс. знаков. + - **Зависимости и риск:** П58, П88; Р-О, Р-К. + +- **П90 · 2025-08 · Исследование UX внутреннего инструмента** + - **Slug:** `editorial-2025-08-practice-tool-ux-research`, `editorial-2025-08-mechanism-tool-ux-research`, `editorial-2025-08-field-tool-ux-research`. + - **Техническая проблема:** интервью подтверждает удобную гипотезу автора, а не проблему коллеги; зафиксировать нейтральный сценарий, согласие и разбор наблюдений. + - **Источники:** исследовательский протокол/форма согласия + обезличенные записи задач и журнал кодирования наблюдений. + - **Визуал:** evidence map и таблица «цитата/действие/неопределённость». + - **Голос / объём:** М8, 8–10 тыс. знаков. + - **Зависимости и риск:** П58, П88, П89; Р-К. + +- **П91 · 2025-09 · Инженерное интервью** + - **Slug:** `editorial-2025-09-practice-engineering-interviews`, `editorial-2025-09-mechanism-engineering-interviews`, `editorial-2025-09-field-engineering-interviews`. + - **Техническая проблема:** интервью проверяет память на термины, а не способ работы с неопределённостью; связать rubric, рабочую пробу и единые критерии review. + - **Источники:** утверждённый rubric/описание роли + обезличенный результат рабочей пробы и review-критериев. + - **Визуал:** матрица компетенций и схема оценивания. + - **Голос / объём:** М8, 8–10 тыс. знаков. + - **Зависимости и риск:** П63, П90; Р-К. + +- **П92 · 2025-10 · Объяснение сложной темы** + - **Slug:** `editorial-2025-10-practice-teaching-engineering`, `editorial-2025-10-mechanism-teaching-engineering`, `editorial-2025-10-field-teaching-engineering`. + - **Техническая проблема:** объяснение даёт рецепт без модели, и читатель копирует код вне условий; соединить исходную задачу, контрпример и короткое упражнение с проверкой. + - **Источники:** исходный код/официальная документация разбираемой технологии + результаты упражнения и обратная связь по pull request-ам. + - **Визуал:** путь «модель → упражнение → проверка» и таблица типичных ошибок. + - **Голос / объём:** М8, 8–11 тыс. знаков. + - **Зависимости и риск:** П91; Р-К. + +- **П93 · 2025-11 · Проверка источников** + - **Slug:** `editorial-2025-11-practice-research-method`, `editorial-2025-11-mechanism-research-method`, `editorial-2025-11-field-research-method`. + - **Техническая проблема:** статья собирает ссылки, но не отделяет версию, маркетинговое обещание и воспроизводимый факт; вести журнал утверждений и первоисточников. + - **Источники:** metadata/release notes первичных публикаций + исследовательский журнал с повторяемой фикстурой. + - **Визуал:** лестница доказательств и таблица статуса утверждений. + - **Голос / объём:** М8, 8–10 тыс. знаков. + - **Зависимости и риск:** П85, П92; Р-В, Р-К. + +- **П94 · 2025-12 · Синтез инженерного года** + - **Slug:** `editorial-2025-12-practice-year-synthesis`, `editorial-2025-12-mechanism-year-synthesis`, `editorial-2025-12-field-year-synthesis`. + - **Техническая проблема:** годовой текст отбирает только успехи и выдаёт корреляцию за эффект; собрать timeline решений, альтернатив и наблюдаемых результатов. + - **Источники:** история изменений/ADR + метрики, результаты проверок и инцидентные артефакты. + - **Визуал:** timeline года и матрица «решение → цена → наблюдение». + - **Голос / объём:** М8, 9–12 тыс. знаков. + - **Зависимости и риск:** П82, П89, П93; Р-О, Р-К. + +### 2026 — сквозная архитектура и техническое лидерство + +> На дату инвентаризации завершены календарные партии до июля 2026 года включительно. Для партий августа–декабря `Р-Ф` означает: сначала подтвердить, что материал может быть опубликован как факт, иначе писать его как явно датированный план или сценарий. + +- **П95 · 2026-01 · API платформенной команды** + - **Slug:** `editorial-2026-01-practice-platform-api`, `editorial-2026-01-mechanism-platform-api`, `editorial-2026-01-field-platform-api`. + - **Техническая проблема:** платформенная абстракция скрывает детали до первого нестандартного потребителя, после чего API начинает протекать; определить контракт, escape hatch и обратную совместимость. + - **Источники:** официальная API-спецификация/стандарты команды + contract tests и трасса двух потребителей. + - **Визуал:** карта публичного API и таблица гарантий/исключений. + - **Голос / объём:** М9, 9–12 тыс. знаков. + - **Зависимости и риск:** П43, П75; Р-В, Р-К. + +- **П96 · 2026-02 · Устойчивость к отказам** + - **Slug:** `editorial-2026-02-practice-resilience`, `editorial-2026-02-mechanism-resilience`, `editorial-2026-02-field-resilience`. + - **Техническая проблема:** retry, fallback и репликация независимо кажутся защитой, а вместе увеличивают нагрузку во время сбоя; разложить каскад и точку ограничения. + - **Источники:** официальная документация компонентов устойчивости + результат failure-injection и трасса распространения ошибки. + - **Визуал:** граф каскада отказа и таблица режимов деградации. + - **Голос / объём:** М9, 9–12 тыс. знаков. + - **Зависимости и риск:** П28, П45, П95; Р-В, Р-О. + +- **П97 · 2026-03 · Контракты данных** + - **Slug:** `editorial-2026-03-practice-data-contracts`, `editorial-2026-03-mechanism-data-contracts`, `editorial-2026-03-field-data-contracts`. + - **Техническая проблема:** schema change координируют вручную между producer и consumer, поэтому версия данных ломается уже после deploy; автоматизировать compatibility gate. + - **Источники:** схема/документация schema registry + CI-проверка совместимости и набор граничных данных. + - **Визуал:** эволюция контракта и матрица producer/consumer. + - **Голос / объём:** М9, 9–12 тыс. знаков. + - **Зависимости и риск:** П42, П65, П95; Р-В. + +- **П98 · 2026-04 · Современная безопасность веба** + - **Slug:** `editorial-2026-04-practice-modern-web-security`, `editorial-2026-04-mechanism-modern-web-security`, `editorial-2026-04-field-modern-web-security`. + - **Техническая проблема:** перечень controls не показывает, какой путь атаки закрыт и что осталось открытым; связать threat model, контрмеру и тест доказательства. + - **Источники:** актуальные официальные advisory/стандарты + модель угроз и результаты разрешённой проверки стенда. + - **Визуал:** карта «атака → защита → доказательство» и матрица остаточного риска. + - **Голос / объём:** М9, 9–13 тыс. знаков. + - **Зависимости и риск:** П59–П70; Р-В, Р-З. + +- **П99 · 2026-05 · Производительность системы** + - **Slug:** `editorial-2026-05-practice-systems-performance`, `editorial-2026-05-mechanism-systems-performance`, `editorial-2026-05-field-systems-performance`. + - **Техническая проблема:** оптимизация одного сервиса не меняет end-to-end latency, потому что ограничение находится в очереди, БД или внешнем вызове; найти bottleneck на единой трассе. + - **Источники:** официальная документация runtime/профайлера + benchmark-harness, trace и конфигурация измерения. + - **Визуал:** critical-path waterfall и график «нагрузка → latency». + - **Голос / объём:** М9, 9–13 тыс. знаков. + - **Зависимости и риск:** П18, П45, П96; Р-В, Р-О. + +- **П100 · 2026-06 · PHP, JavaScript и D в одном ландшафте** + - **Slug:** `editorial-2026-06-practice-multi-runtime`, `editorial-2026-06-mechanism-multi-runtime`, `editorial-2026-06-field-multi-runtime`. + - **Техническая проблема:** языковые границы маскируют разные модели ошибок, типов и времени выполнения; выбрать один контракт между runtime-ами и подтвердить его интеграционным тестом. + - **Источники:** официальная документация всех трёх runtime-ов + контрактный тест, profile и трасса межсервисного запроса. + - **Визуал:** карта совместимости runtime-ов и таблица границ контракта. + - **Голос / объём:** М9, 9–12 тыс. знаков. + - **Зависимости и риск:** П44, П97, П99; Р-В, Р-О. + +- **П101 · 2026-07 · План миграции** + - **Slug:** `editorial-2026-07-practice-migration-playbook`, `editorial-2026-07-mechanism-migration-playbook`, `editorial-2026-07-field-migration-playbook`. + - **Техническая проблема:** план описывает целевую архитектуру, но не включает инвентарь, перевод трафика, миграцию данных и rollback; задать контрольные точки с наблюдаемыми критериями. + - **Источники:** официальная документация целевой технологии + репозиторный инвентарь, rehearsal и метрики переходного периода. + - **Визуал:** фазы миграции с decision gate и таблица rollback. + - **Голос / объём:** М9, 10–13 тыс. знаков. + - **Зависимости и риск:** П71, П74, П81, П96; Р-В, Р-О, Р-К. + +- **П102 · 2026-08 · End-to-end наблюдаемость** + - **Slug:** `editorial-2026-08-practice-end-to-end-observability`, `editorial-2026-08-mechanism-end-to-end-observability`, `editorial-2026-08-field-end-to-end-observability`. + - **Техническая проблема:** данные теряют correlation ID между UI, API и worker-ом, а наблюдаемость создаёт лишние персональные или высококардинальные данные; спроектировать сквозную связь и sampling. + - **Источники:** спецификация OpenTelemetry + полноценный trace/log/metric-набор одного сценария. + - **Визуал:** карта сигналов по границам системы и график cardinality. + - **Голос / объём:** М9, 9–13 тыс. знаков. + - **Зависимости и риск:** П67, П96, П101; Р-В, Р-О, Р-Ф. + +- **П103 · 2026-09 · Граница frontend и backend** + - **Slug:** `editorial-2026-09-practice-frontend-backend-boundary`, `editorial-2026-09-mechanism-frontend-backend-boundary`, `editorial-2026-09-field-frontend-backend-boundary`. + - **Техническая проблема:** UI присваивает себе доменное состояние или API возвращает экранную модель, из-за чего обе стороны нельзя менять отдельно; разделить ответственность по контексту и контракту. + - **Источники:** API-спецификация/код границы + UI-network trace и контрактный тест сценария. + - **Визуал:** карта владения состоянием и таблица ответственности. + - **Голос / объём:** М9, 9–12 тыс. знаков. + - **Зависимости и риск:** П47, П95, П97; Р-В, Р-К, Р-Ф. + +- **П104 · 2026-10 · Стандарт code review** + - **Slug:** `editorial-2026-10-practice-code-review-standard`, `editorial-2026-10-mechanism-code-review-standard`, `editorial-2026-10-field-code-review-standard`. + - **Техническая проблема:** review застревает в стиле и не видит риск контракта, миграции или эксплуатации; описать входные доказательства и stop-the-line критерии. + - **Источники:** правила review/история pull request-ов + дефекты, CI-артефакты и изменения после review. + - **Визуал:** матрица review-решений и схема эскалации риска. + - **Голос / объём:** М9, 9–12 тыс. знаков. + - **Зависимости и риск:** П63, П91, П103; Р-О, Р-К, Р-Ф. + +- **П105 · 2026-11 · Сравнение технологий без хайпа** + - **Slug:** `editorial-2026-11-practice-technology-evaluation`, `editorial-2026-11-mechanism-technology-evaluation`, `editorial-2026-11-field-technology-evaluation`. + - **Техническая проблема:** сравнение берёт популярность или benchmark без контекста и игнорирует стоимость внедрения; формализовать критерии, веса, воспроизводимое измерение и стоп-факторы. + - **Источники:** официальные документация, лицензия и release notes кандидатов + собственный benchmark/прототип с опубликованной конфигурацией. + - **Визуал:** взвешенная decision matrix и график trade-off. + - **Голос / объём:** М9, 9–13 тыс. знаков. + - **Зависимости и риск:** П99, П100, П104; Р-В, Р-О, Р-К, Р-Ф. + +- **П106 · 2026-12 · Инженерный кейс от проблемы до результата** + - **Slug:** `editorial-2026-12-practice-portfolio-case`, `editorial-2026-12-mechanism-portfolio-case`, `editorial-2026-12-field-portfolio-case`. + - **Техническая проблема:** кейс скрывает исходные ограничения, неудачные варианты и стоимость поддержки, поэтому превращается в рекламу; показать цепочку «контекст → решение → доказательство → остаточный риск». + - **Источники:** ADR/артефакты проекта + метрики, результаты тестов, инциденты или change log. + - **Визуал:** причинная timeline кейса и таблица компромиссов. + - **Голос / объём:** М9, 10–13 тыс. знаков. + - **Зависимости и риск:** П94, П101, П105; Р-О, Р-К, Р-Ф. + +### 2027 — ретроспектива, капстоуны и наставничество + +> Все партии этого раздела несут `Р-Ф`: до наступления их календарной даты нельзя писать так, будто будущая версия инструмента, исследование или наблюдение уже существуют. Публикация допустима только после свежей сверки источников либо в форме явно помеченного плана/сценария. + +- **П107 · 2027-01 · Десять лет web-диагностики** + - **Slug:** `editorial-2027-01-practice-debugging-decade`, `editorial-2027-01-mechanism-debugging-decade`, `editorial-2027-01-field-debugging-decade`. + - **Техническая проблема:** ретроспектива превращает разные сбои в красивый «универсальный метод»; сопоставить ранние логи, современные trace и неизменный порядок проверки гипотез. + - **Источники:** исходные bug report/log-артефакты разных лет + актуальная документация и повторный repro выбранных случаев. + - **Визуал:** эволюционная timeline диагностики и таблица «сигнал → инструмент → предел». + - **Голос / объём:** М10, 9–13 тыс. знаков. + - **Зависимости и риск:** П01, П29, П102; Р-В, Р-Ф. + +- **П108 · 2027-02 · Уроки Bitrix для legacy-разработки** + - **Slug:** `editorial-2027-02-practice-bitrix-lessons`, `editorial-2027-02-mechanism-bitrix-lessons`, `editorial-2027-02-field-bitrix-lessons`. + - **Техническая проблема:** старые API-примеры продолжают копировать без версии, а уроки о legacy звучат как оправдание неизменности; отделить сохраняющийся принцип от устаревшего вызова. + - **Источники:** архивная и актуальная документация/исходный код Bitrix + migration test на выбранном сценарии. + - **Визуал:** decision tree «сохранить / обернуть / заменить» и таблица версионных ограничений. + - **Голос / объём:** М10, 9–13 тыс. знаков. + - **Зависимости и риск:** январская серия 2018, П03, П11, П71; Р-В, Р-Ф. + +- **П109 · 2027-03 · Уроки D для прикладного инженера** + - **Slug:** `editorial-2027-03-practice-d-lessons`, `editorial-2027-03-mechanism-d-lessons`, `editorial-2027-03-field-d-lessons`. + - **Техническая проблема:** личный опыт с языком подают как сравнение экосистем без условий; повторно измерить один сервисный сценарий и честно назвать, где D не является выбором по умолчанию. + - **Источники:** официальная спецификация и release notes D + исходник benchmark-а и повторный profile. + - **Визуал:** график trade-off runtime-ов и таблица ограничений. + - **Голос / объём:** М10, 9–13 тыс. знаков. + - **Зависимости и риск:** линия 2017 D, П44, П100; Р-В, Р-О, Р-Ф. + +- **П110 · 2027-04 · Эволюция frontend-сборки** + - **Slug:** `editorial-2027-04-practice-build-evolution`, `editorial-2027-04-mechanism-build-evolution`, `editorial-2027-04-field-build-evolution`. + - **Техническая проблема:** новая сборка объявляется автоматически лучше старой, хотя меняются тип приложения, кеширование и CI; сравнивать конфигурации и артефакты, а не названия инструментов. + - **Источники:** исторические config/CI-артефакты + актуальная официальная документация сборщиков и результаты одинакового build-замера. + - **Визуал:** timeline эволюции сборки и таблица сравнимых условий. + - **Голос / объём:** М10, 9–13 тыс. знаков. + - **Зависимости и риск:** П05, П21, П77; Р-В, Р-О, Р-Ф. + +- **П111 · 2027-05 · Полевой справочник HTTP и TLS** + - **Slug:** `editorial-2027-05-practice-http-tls-guide`, `editorial-2027-05-mechanism-http-tls-guide`, `editorial-2027-05-field-http-tls-guide`. + - **Техническая проблема:** «советы по HTTP/TLS» стареют вместе с протоколами и настройками клиентов; ограничить справочник диагностическими вопросами, версией и проверяемой командой. + - **Источники:** RFC/errata и официальная документация OpenSSL/cURL + контролируемые handshake и HTTP-трассы. + - **Визуал:** карта handshake/заголовков и матрица симптомов. + - **Голос / объём:** М10, 10–14 тыс. знаков. + - **Зависимости и риск:** П06, П07, П98; Р-В, Р-З, Р-Ф. + +- **П112 · 2027-06 · Большой разбор производительности** + - **Slug:** `editorial-2027-06-practice-performance-capstone`, `editorial-2027-06-mechanism-performance-capstone`, `editorial-2027-06-field-performance-capstone`. + - **Техническая проблема:** улучшение одного числа выдают за системный эффект без профиля нагрузки и критического пути; связать workload, инструмент, bottleneck и повторный замер. + - **Источники:** benchmark-harness/конфигурация среды + сырые telemetry и profile-артефакты. + - **Визуал:** end-to-end critical path и график latency/throughput. + - **Голос / объём:** М10, 10–15 тыс. знаков. + - **Зависимости и риск:** П18, П45, П99; Р-О, Р-Ф. + +- **П113 · 2027-07 · Большой разбор надёжности** + - **Slug:** `editorial-2027-07-practice-reliability-capstone`, `editorial-2027-07-mechanism-reliability-capstone`, `editorial-2027-07-field-reliability-capstone`. + - **Техническая проблема:** reliability сводят к retry и replica, не считая восстановление, нагрузку и организационную реакцию; показать систему ограничений через один отказный сценарий. + - **Источники:** incident records/runbook + результаты failure-injection и SLO-метрики. + - **Визуал:** fault tree, error budget и timeline восстановления. + - **Голос / объём:** М10, 10–15 тыс. знаков. + - **Зависимости и риск:** П28, П34, П96; Р-О, Р-К, Р-Ф. + +- **П114 · 2027-08 · Большой разбор безопасности** + - **Slug:** `editorial-2027-08-practice-security-capstone`, `editorial-2027-08-mechanism-security-capstone`, `editorial-2027-08-field-security-capstone`. + - **Техническая проблема:** «best practices» не доказывают, что путь атаки закрыт; провести от актива через угрозу к контролю и доказательству тестом. + - **Источники:** актуальные официальные security advisory/стандарты + threat model и результаты разрешённого security-теста. + - **Визуал:** attack tree и карта контролей. + - **Голос / объём:** М10, 10–15 тыс. знаков. + - **Зависимости и риск:** П59–П70, П98; Р-В, Р-З, Р-Ф. + +- **П115 · 2027-09 · Серия для инженера, который растёт** + - **Slug:** `editorial-2027-09-practice-mentor-series`, `editorial-2027-09-mechanism-mentor-series`, `editorial-2027-09-field-mentor-series`. + - **Техническая проблема:** наставничество даёт абстрактный список навыков и не создаёт доказательства роста; связать ситуацию, упражнение, review и следующий уровень самостоятельности. + - **Источники:** учебная программа/репозиторные примеры + обезличенные результаты упражнений и обратная связь. + - **Визуал:** skill map и петля deliberate practice. + - **Голос / объём:** М10, 9–13 тыс. знаков. + - **Зависимости и риск:** П91, П92, П106; Р-К, Р-Ф. + +- **П116 · 2027-10 · Длинное техническое интервью** + - **Slug:** `editorial-2027-10-practice-long-form-interview`, `editorial-2027-10-mechanism-long-form-interview`, `editorial-2027-10-field-long-form-interview`. + - **Техническая проблема:** интервью превращается в авторитетный рассказ без проверяемых опор; сопоставить согласованный transcript с кодом, метриками и источниками, а не усиливать реплику редактурой. + - **Источники:** утверждённая расшифровка/первичные артефакты собеседника + независимые технические документы, код или измерения. + - **Визуал:** карта «утверждение → подтверждение → ограничение». + - **Голос / объём:** М10, 10–14 тыс. знаков. + - **Зависимости и риск:** П91, П93, П106; Р-К, Р-Ф. + +- **П117 · 2027-11 · Пересмотр старых советов** + - **Slug:** `editorial-2027-11-practice-mistakes-revisions`, `editorial-2027-11-mechanism-mistakes-revisions`, `editorial-2027-11-field-mistakes-revisions`. + - **Техническая проблема:** старый совет остаётся в поиске после смены версии или появления контрпримера; сравнить исходный текст, его условия и свежий repro, затем выпустить корректировку. + - **Источники:** историческая статья/снимок старой документации + актуальная документация и повторный тест. + - **Визуал:** timeline исправлений и таблица «было → почему неверно → теперь». + - **Голос / объём:** М10, 9–13 тыс. знаков. + - **Зависимости и риск:** П107–П111; Р-В, Р-З, Р-Ф. + +- **П118 · 2027-12 · Манифест инженерного письма** + - **Slug:** `editorial-2027-12-practice-author-manifesto`, `editorial-2027-12-mechanism-author-manifesto`, `editorial-2027-12-field-author-manifesto`. + - **Техническая проблема:** манифест легко становится набором лозунгов, не отражающим реальную редакционную практику; вывести правила из разобранных решений, ревью и исправлений, а не объявить их истиной. + - **Источники:** архив опубликованных статей/изменений и редакционные review-записи + данные о проверках, исправлениях и обратной связи. + - **Визуал:** график эволюции редакционных решений и rubric качества статьи. + - **Голос / объём:** М10, 10–15 тыс. знаков. + - **Зависимости и риск:** П94, П115–П117; Р-К, Р-Ф. + +## Зависимости и порядок запуска + +1. **Не переписывать партии из середины траектории раньше опорных.** П01–П11 дают словарь диагностики, интеграций и примеров. П12–П22 расширяют его на браузер, HTTP, API и данные. Это сохраняет правдоподобное развитие автора. +2. **Исследование ведётся партиями, а не одним архивным поиском.** Для каждой партии сначала фиксируются версии и два первичных источника, затем создаётся собственный визуал, после чего пишутся три разные статьи. Общая визуальная «обложка года» не заменяет объясняющую схему внутри статьи. +3. **Артефакты переходят только как зависимость, а не как текстовый шаблон.** Например, П29–П31 можно использовать для терминов наблюдаемости в П67 и П102, но пример, данные и вывод обязаны быть новыми. +4. **Будущие даты блокируют публикационные утверждения.** П102–П118 требуют отдельной проверки календарной актуальности; если факт нельзя подтвердить на дату выпуска, материал остаётся в очереди либо маркируется как план/сценарий. +5. **О-01 не расширять искусственно.** Одиночная статья идёт в том же редакционном окне, что П09 и П12, с полной тройной вычиткой. Добавление фиктивной третьей статьи нарушит сохранность slug-архива. + +## Обязательный выход каждой партии + +- Три разных материала (или один для О-01), каждый в пределах 5–15 тыс. знаков основного текста. +- Два проверенных первичных источника с датой и версией. +- Один воспроизводимый пример, минимум одна таблица и один объясняющий визуал с осмысленными `alt` и подписью. +- Три отдельных результата ревью: факты и техника; редактура и голос; визуал и выпуск. +- Перед публикацией — аудит партии, проверка JSON и production-сборка. Партия не считается завершённой по числу переписанных slug без этих доказательств. diff --git a/editorial/reviews/2018-01.md b/editorial/reviews/2018-01.md index 92efe27..308d360 100644 --- a/editorial/reviews/2018-01.md +++ b/editorial/reviews/2018-01.md @@ -22,7 +22,7 @@ - Проблема названа в первом абзаце, а финал даёт проверяемый следующий шаг. - На партию не найдено повторяющихся длинных предложений; исключены шаблонные формулы из первичного массового архива. - Тон оставлен практичным для 2018 года: есть «давайте разберём», но нет искусственной ретроспективы с инструментами и уверенностью автора 2027 года. -- Глубина после финальной правки: 5 102, 5 634 и 5 085 символов обычного текста; 9, 9 и 10 минут чтения соответственно. +- Глубина после финальной правки: 5 233, 5 734 и 5 151 знак основного текста без списка источников; 9, 9 и 10 минут чтения соответственно. ## 3. Визуал и выпуск — пройдено diff --git a/editorial/reviews/2018-02-draft.md b/editorial/reviews/2018-02-draft.md new file mode 100644 index 0000000..7777128 --- /dev/null +++ b/editorial/reviews/2018-02-draft.md @@ -0,0 +1,65 @@ +# Черновое тройное ревью — февраль 2018 + +Партия не интегрирована в `web/data/articles.json`. Ревизии доступны только через: + +```bash +node web/scripts/upgrade-2018-02.mjs --print-revisions +``` + +## 1. Факты и техника + +| Слаг | Главный вопрос | Проверенные первичные источники | Результат | +| --- | --- | --- | --- | +| `editorial-2018-02-practice-php-diagnostics` | Как оставить диагностический факт при 500 и фатальной ошибке PHP? | [set_error_handler](https://www.php.net/manual/en/function.set-error-handler.php), [set_exception_handler](https://www.php.net/manual/en/function.set-exception-handler.php), [register_shutdown_function](https://www.php.net/manual/en/function.register-shutdown-function.php), [error_get_last](https://www.php.net/manual/en/function.error-get-last.php) | Пример не обещает перехватить ошибки до регистрации обработчиков; отдельно названы ограничения фатального пути. | +| `editorial-2018-02-mechanism-php-diagnostics` | Почему строка от `curl_exec()` не означает успех API-операции? | [curl_exec](https://www.php.net/manual/en/function.curl-exec.php), [curl_getinfo](https://www.php.net/manual/en/function.curl-getinfo.php), [curl_errno](https://www.php.net/manual/en/function.curl-errno.php), [RFC 7231](https://www.rfc-editor.org/rfc/rfc7231#section-6) | Строго разделены ошибка cURL, HTTP-статус и контракт тела; повтор записи не рекомендован без идемпотентности. | +| `editorial-2018-02-field-php-diagnostics` | Как отделить битый JSON от корректного `null`? | [json_decode](https://www.php.net/manual/en/function.json-decode.php), [json_last_error](https://www.php.net/manual/en/function.json-last-error.php), [RFC 8259](https://www.rfc-editor.org/rfc/rfc8259.html) | Используется подход PHP 7.1; отмечено, что `JSON_THROW_ON_ERROR` появился только в PHP 7.3. | + +Проверено вручную: + +- В каждой статье не меньше двух официальных или первичных источников. +- Утверждение о статусах HTTP ограничено протокольным уровнем; успех бизнес-операции проверяется проектным контрактом. +- В журналы не предлагается писать пароли, токены, исходное тело запроса или полный ответ партнёра. +- У примеров есть версия и границы: PHP 7.1, проектные таймауты, отсутствие универсального retry. + +## 2. Редактура и голос + +| Слаг | Проблема в начале | Техническая речь и тон 2018 | Объём основного текста | +| --- | --- | --- | --- | +| `editorial-2018-02-practice-php-diagnostics` | Ответ 500 без причины в журнале | Короткая практическая заметка: контекст, этап, обработчик, проверка | Проверяется скриптом, диапазон 5 000–15 000 знаков | +| `editorial-2018-02-mechanism-php-diagnostics` | Строка от cURL ошибочно объявляется успешной интеграцией | Симптом → уровень сбоя → запись в журнал → действие | Проверяется скриптом, диапазон 5 000–15 000 знаков | +| `editorial-2018-02-field-php-diagnostics` | `if (!$data)` склеивает несколько разных состояний | Один вопрос о JSON, затем конкретные значения и контракт | Проверяется скриптом, диапазон 5 000–15 000 знаков | + +Проверено вручную: + +- У каждой статьи один главный вопрос; темы не копируют друг друга: runtime PHP, transport/HTTP и payload JSON. +- Изъяты общие вводные о «важности» и обещания универсального решения. +- Использованы проектные оговорки вместо выдуманных цифр, версий партнёрских API или результатов замеров. +- Статьи заканчиваются действиями и ограничениями до списка источников. + +## 3. Визуал и выпуск + +| Материал | Назначение | Проверка | +| --- | --- | --- | +| `php-fatal-context-flow.svg` | Показывает три ветки диагностики PHP и общий контекст операции | SVG содержит `title`, `desc`, осмысленный `alt` и подпись в статье | +| `curl-outcome-classifier.svg` | Разделяет транспорт, HTTP и контракт тела | SVG содержит `title`, `desc`, осмысленный `alt` и подпись в статье | +| `json-payload-diagnostic.svg` | Разделяет ошибку декодера и нарушение JSON-контракта | SVG содержит `title`, `desc`, осмысленный `alt` и подпись в статье | + +Проверки к запуску перед интеграцией: + +```bash +node --check web/scripts/upgrade-2018-02.mjs +node web/scripts/upgrade-2018-02.mjs --print-revisions | jq 'length' +xmllint --noout web/public/assets/editorial/2018/php-fatal-context-flow.svg +xmllint --noout web/public/assets/editorial/2018/curl-outcome-classifier.svg +xmllint --noout web/public/assets/editorial/2018/json-payload-diagnostic.svg +``` + +После интеграции основной агент должен запустить общий audit-скрипт и production-сборку. Этот авторский черновик их намеренно не запускает: он не меняет архив. + +### Результаты авторского прохода + +- `node --check web/scripts/upgrade-2018-02.mjs` — PASS. +- `node web/scripts/upgrade-2018-02.mjs --print-revisions` вернул массив из трёх ревизий; встроенная проверка структуры прошла. +- Длина основного текста без источников: 7 109, 6 519 и 6 706 знаков соответственно; все значения в диапазоне 5 000–15 000. +- `xmllint --noout` для трёх SVG — PASS. +- Локальная браузерная отрисовка SVG заблокирована политикой среды, поэтому финальный просмотр на desktop и узком экране остаётся выпускной проверкой после интеграции в страницу. Структурная проверка выполнена: у каждого SVG единый `viewBox 1200×620`, описание `title`/`desc` и все координаты элементов лежат внутри полотна. diff --git a/editorial/reviews/2018-03-draft.md b/editorial/reviews/2018-03-draft.md new file mode 100644 index 0000000..d04b41e --- /dev/null +++ b/editorial/reviews/2018-03-draft.md @@ -0,0 +1,65 @@ +# Март 2018 — безопасная загрузка файлов: draft-review + +Статус: принято в публикационный слой 31 июля 2026 после независимого audit. Эта партия существует как три ревизии из `web/scripts/upgrade-2018-03.mjs --print-revisions`; слой `web/data/editorial-revisions.mjs` сопоставляет их только по стабильным slug, не меняя даты, автора или историю Git. + +| Slug | Главный вопрос | Основной текст без источников | +| --- | --- | ---: | +| `editorial-2018-03-practice-safe-uploads` | Как принять JPEG или PNG для аватара без доверия к имени и MIME-типу формы? | 5 666 знаков | +| `editorial-2018-03-mechanism-safe-uploads` | Какие признаки файла можно использовать для какой проверки? | 6 209 знаков | +| `editorial-2018-03-field-safe-uploads` | Как выдать владельцу приватный PDF, если файл хранится вне веб-корня? | 5 688 знаков | + +## 1. Факты и техника — пройдено + +### Практика: приём аватара + +- Проверены [коды ошибок загрузки PHP](https://www.php.net/manual/en/features.file-upload.errors.php), [move_uploaded_file](https://www.php.net/manual/en/function.move-uploaded-file.php), [finfo_file](https://www.php.net/manual/en/function.finfo-file.php), [ограничение getimagesize как валидатора](https://www.php.net/manual/en/function.getimagesize.php) и [OWASP File Upload Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html). +- Пример проверяет `UPLOAD_ERR_OK`, прикладной лимит, MIME-тип через Fileinfo и размеры изображения до переноса. `getimagesize()` используется только для размеров, не как доказательство корректности изображения. +- Ограничения названы прямо: нет антивирусной проверки, CSRF-защиты и обработки миниатюр. + +### Механизм: границы доверия + +- Проверены [RFC 7578 для multipart/form-data](https://www.rfc-editor.org/rfc/rfc7578), [коды ошибок PHP](https://www.php.net/manual/en/features.file-upload.errors.php), [Fileinfo](https://www.php.net/manual/en/function.finfo-file.php), [getimagesize](https://www.php.net/manual/en/function.getimagesize.php) и [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html). +- Воспроизводимый `curl`-пример не утверждает конкретный результат базы magic: он показывает разницу между заявленным клиентом `type` и типом, который определяет Fileinfo. +- Статья не называет Fileinfo антивирусом и не переносит ответственность за лимит всего запроса на одну PHP-функцию. + +### Поле: выдача приватного PDF + +- Проверены [OWASP для размещения файлов вне webroot](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html), [header()](https://www.php.net/manual/en/function.header.php), [readfile()](https://www.php.net/manual/en/function.readfile.php) и [RFC 6266 для Content-Disposition](https://www.rfc-editor.org/rfc/rfc6266). +- В коде ID документа и текущий пользователь участвуют в одном SQL-запросе; путь строится только из ключа, прошедшего контрактное регулярное выражение. +- Ограничения не скрыты: в примере нет Range, кеширования, ограничения частоты и эффективной выдачи больших файлов. + +Версионная оговорка: код ориентирован на PHP 7.2. В статьях не используются приёмы, добавленные позднее; актуальные страницы PHP Manual взяты как первичный справочник функций и их ограничений. + +## 2. Редактура и голос — пройдено после исправления стоп-условия + +- Каждая статья отвечает на один вопрос и начинает с наблюдаемой ситуации, а не с общего рассуждения о безопасности. +- Для `mechanism` и `field` первоначальный строгий audit обнаружил недостаточно явную постановку проблемы. В первые два предложения добавлены формулировки `Симптом:` и `Цена ошибки`; сильная исходная подводка сохранена дальше в том же абзаце. +- Повторный CLI-audit подтвердил: обе статьи содержат симптом и цену ошибки в первых 420 знаках; длины — 6 209 и 5 688 знаков соответственно. +- Речь соответствует 2018 году: короткие технические абзацы, «давайте» и «я бы» только там, где автор делает практический вывод; нет обещаний универсального решения, поздних инструментов и шаблонных оборотов. +- Во всех трёх ревизиях есть проблема, таблица, воспроизводимый пример, порядок действий, ограничения, один рисунок и минимум четыре первичных или нормативных источника. + +## 3. Визуал и выпуск — пройдено для черновика + +- `xmllint --noout` прошёл для трёх SVG: `php-upload-avatar-contract.svg`, `php-upload-trust-signals.svg`, `php-private-download-flow.svg`. +- В локальном рендере SVG проверены title, доступное описание, границы текста и масштаб 1280×720: 31, 26 и 20 текстовых узлов соответственно; выходов за границы нет. +- На первой схеме во время визуальной проверки найден и исправлен контраст номеров этапов: цвет изменён с белого на тёмный `rgb(46, 82, 103)`. +- Каждый рисунок будет иметь осмысленный `alt` и подпись через данные ревизии. Таблицы обёрнуты в `table-scroll`; текущие стили блога добавляют горизонтальную прокрутку при минимальной ширине таблицы 620px. +- Production-сборка и проверка опубликованных URL не запускались намеренно: статьи ещё не интегрированы в `web/data/articles.json`. Это выпускной шаг основного агента, а не основание менять архив из этой ветки. + +## Повторённые команды + +```sh +node --check web/scripts/upgrade-2018-03.mjs +node web/scripts/upgrade-2018-03.mjs --print-revisions +xmllint --noout \ + web/public/assets/editorial/2018/php-upload-avatar-contract.svg \ + web/public/assets/editorial/2018/php-upload-trust-signals.svg \ + web/public/assets/editorial/2018/php-private-download-flow.svg +``` + +Результат: три ревизии готовы для точечной интеграции без перезаписи остальных статей. + +## Приёмка основного агента + +- Повторно пройден строгий `audit-quality-batch.mjs`: 5 666 / 6 209 / 5 688 знаков основного текста; в каждой статье найдены рисунок с `alt`, таблица, код, порядок действий и отдельный раздел источников. +- Проверены безопасный import модуля и CLI-вывод ровно трёх ревизий. Production build после интеграции прошёл и сгенерировал 374 статические страницы. diff --git a/editorial/reviews/2018-04-draft.md b/editorial/reviews/2018-04-draft.md new file mode 100644 index 0000000..21f99df --- /dev/null +++ b/editorial/reviews/2018-04-draft.md @@ -0,0 +1,34 @@ +# Апрель 2018 — черновики о символьных кодах и ЧПУ + +Статус: готово к интеграции основным агентом. Этот черновик не изменяет web/data/articles.json. + +| Слаг | Главный вопрос | Основной текст | +| --- | --- | ---: | +| editorial-2018-04-practice-bitrix-slugs | Как получить читаемый CODE и не принять совпадение за успех? | 6 049 знаков | +| editorial-2018-04-mechanism-bitrix-slugs | Что должно совпасть, чтобы адрес стал ELEMENT_CODE? | 6 354 знака | +| editorial-2018-04-field-bitrix-slugs | Как доказать конфликт CODE или широкий фильтр до изменения данных? | 7 111 знаков | + +## Ревью 1. Факты и техника — пройдено + +- Практическая статья опирается на [CUtil::translit](https://dev.1c-bitrix.ru/api_help/main/reference/cutil/translit.php), [CIBlockElement::GetList](https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/getlist.php?print=Y) и [CIBlockElement::Add](https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/add.php?print=Y). Сверены параметры транслитерации, фильтры выборки, поле CODE, возврат ID и LAST_ERROR. +- Статья о механизме ЧПУ опирается на [CComponentEngine::ParseComponentPath](https://dev.1c-bitrix.ru/api_help/main/reference/ccomponentengine/parsecomponentpath.php), [CComponentEngine::MakePathFromTemplate](https://dev.1c-bitrix.ru/api_help/main/reference/ccomponentengine/makepathfromtemplate.php) и [CIBlockElement::GetList](https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/getlist.php?print=Y). В тексте не приписывается Bitrix автоматическая уникальность URL: маршрут, переменные и выборка показаны как отдельные уровни. +- Диагностическая статья опирается на [CIBlockElement::GetList](https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/getlist.php?print=Y), [CComponentEngine::ParseComponentPath](https://dev.1c-bitrix.ru/api_help/main/reference/ccomponentengine/parsecomponentpath.php) и [CIBlockElement::Update](https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/update.php?print=Y). Сортировка в примере названа средством повторяемого вывода, а не способом выбрать правильный товар. +- Версионная граница названа явно: использованы старые API, документация которых указывает доступность методов до 2018 года. Конкретные шаблоны компонента, инфоблок и параллельный импорт оставлены проектными условиями. +- Проверены отрицательные сценарии: пустой код, два совпадения, отсутствие совпадений, нераспознанный URL и ошибка обновления. + +## Ревью 2. Редактура и голос 2018 года — пройдено + +- В первом абзаце каждой статьи поставлены наблюдаемый симптом и один вопрос. Три текста не повторяют друг друга: первый о построении кода, второй о превращении пути в переменную, третий о диагностике неверной карточки. +- Основной текст укладывается в требуемые 5 000–15 000 знаков. Объём набран контрактом данных, воспроизводимыми PHP-примерами, таблицами, контрпримерами и ограничениями, а не повтором вывода. +- Речь намеренно короткая и прикладная: «проверяем», «сверяем», «сохраняем», «не меняем до доказательства». Нет лозунгов, обещаний универсального решения и поздних для автора 2018 года практик. +- Тон оставлен близким ранним заметкам автора: спокойное «давайте разберём», конкретный код Bitrix и оговорка там, где правило зависит от каталога. +- После вычитки удалены общие фразы о важности темы. Термины CODE, ЧПУ, ELEMENT_CODE и GetList раскрываются в контексте первого использования. + +## Ревью 3. Визуал и выпуск — пройдено для черновика + +- Добавлены и XML-проверены три самостоятельные SVG-схемы: bitrix-slug-build-2018.svg, bitrix-slug-route-2018.svg и bitrix-slug-conflict-2018.svg. +- Все SVG прошли xmllint --noout. Схемы отрендерены в PNG и просмотрены вручную в масштабе 1600 px. Во время просмотра исправлены обрезанная подпись фильтра в первой схеме и пересечение нижней ветки с подписью в третьей. +- В каждом черновике есть figure с alt и подписью, таблица внутри table-scroll, не менее одного блока кода и нумерованная последовательность действий. +- node --check web/scripts/upgrade-2018-04.mjs завершился без ошибок. node web/scripts/upgrade-2018-04.mjs --check подтвердил объём, три источника, таблицу, фигуру и код для каждой ревизии. +- node web/scripts/upgrade-2018-04.mjs --print-revisions выдаёт валидный JSON-массив ровно из трёх ревизий. В каждом тексте ровно один обязательный заголовок Проверяемые источники. +- Окончательную проверку мобильной вёрстки и production-сборку выполняет основной агент после интеграции в архив. diff --git a/editorial/reviews/2018-05-draft.md b/editorial/reviews/2018-05-draft.md new file mode 100644 index 0000000..fa5825f --- /dev/null +++ b/editorial/reviews/2018-05-draft.md @@ -0,0 +1,62 @@ +# Черновое ревью — май 2018: legacy jQuery + +Статус: принято в публикационный слой 31 июля 2026 после независимого audit. Партия намеренно не перезаписывает `web/data/articles.json`: `web/data/editorial-revisions.mjs` накладывает ревизии только по стабильным slug. + +## Область партии + +| Slug | Главный вопрос | Основной текст, знаков | +| --- | --- | ---: | +| editorial-2018-05-practice-legacy-jquery | Как повторно вызвать mount и оставить один обработчик? | 7 718 | +| editorial-2018-05-mechanism-legacy-jquery | Почему прямой click пропадает после .html()? | 8 088 | +| editorial-2018-05-field-legacy-jquery | Как держать один активный Ajax-запрос формы? | 8 729 | + +Размеры посчитаны без списка источников и HTML-разметки. Каждый текст отвечает на один вопрос, начинает с наблюдаемого сбоя и заканчивается последовательностью действий с ограничениями. + +## Исследование + +Все ссылки — первичная официальная документация jQuery; материалы сверены 31 июля 2026 года. + +| Статья | Источники | +| --- | --- | +| Повторный mount | [.on()](https://api.jquery.com/on/), [.off()](https://api.jquery.com/off/) | +| Замена DOM | [.html()](https://api.jquery.com/html/), [.on()](https://api.jquery.com/on/), [.off()](https://api.jquery.com/off/), [.data()](https://api.jquery.com/data/) | +| Ajax-форма | [jQuery.ajax()](https://api.jquery.com/jQuery.ajax/), [.serialize()](https://api.jquery.com/serialize/), [.prop()](https://api.jquery.com/prop/), [.data()](https://api.jquery.com/data/), [.removeData()](https://api.jquery.com/removeData/), [deferred.always()](https://api.jquery.com/deferred.always/) | + +## Ревью 1 — факты и техника + +**Пройдено.** + +- Для mount проверены: пространства имён событий, снятие обработчиков по namespace, различие прямой и делегированной привязки, а также версия .on()/.off() — jQuery 1.7+. +- Для .html() проверено основное утверждение: jQuery удаляет данные и события дочерних узлов до замены содержимого. Ограничения делегирования для SVG и не всплывающих событий названы явно. Риск вставки непроверенной HTML-строки не выдан за проблему конкретного API — это предупреждение документации. +- Для Ajax-формы проверены: состав .serialize(), динамическое свойство disabled через .prop(), хранение и удаление маркера через .data()/.removeData(), роли done, fail и always у jqXHR. always используется только для освобождения UI и не анализирует разнородные аргументы resolve/reject. +- Серверные последствия не выдуманы: клиентский замок ограничен текущим DOM-экземпляром; при timeout текст не обещает, что операция не была выполнена. + +## Ревью 2 — редактура и голос + +**Пройдено.** + +- Речь короткая и техническая: симптом → причина → проверка → действие. Нет обещаний «универсального» решения и абстрактных вступлений о важности темы. +- Голос соответствует 2018 году: код на jQuery, IIFE, var, $.ajax; без искусственного переноса поздних фреймворков или роли техлида в раннюю заметку. +- У каждой статьи свой сценарий, таблица, воспроизводимый код и порядок внедрения. Три текста не повторяют один и тот же вывод под разными заголовками. +- Убрано неподтверждённое обобщение о распространённости конкретной версии jQuery в 2018 году; примеры лишь задают поддерживаемую версию. + +## Ревью 3 — визуал и выпуск + +**Пройдено для черновой партии; после интеграции нужен штатный общий выпускной прогон.** + +- Три SVG валидированы командой xmllint --noout и открыты в локальном рендере. После финальной правки нет обрезанных заголовков, стрелок или кодовых строк. +- У каждого будущего contentHtml есть один figure с осмысленными alt и подписью, одна таблица в обёртке div.table-scroll и три блока кода. +- В существующих стилях .table-scroll имеет горизонтальную прокрутку, а таблица — min-width: 620px; это сохраняет читаемость на узком экране без сжатия ячеек. +- Команда node --check web/scripts/upgrade-2018-05.mjs прошла. Команда node web/scripts/upgrade-2018-05.mjs --print-revisions печатает валидный JSON ровно трёх ревизий и не записывает архив. + +## Перед интеграцией + +1. Основной агент импортирует JSON из параметра --print-revisions и сопоставляет только три перечисленных slug. +2. Запускаются общий аудит партии и production-сборка. +3. На собранных страницах повторяется проверка таблиц в узком viewport, потому что эта ветка по задаче не изменяет articles.json. + +## Приёмка основного агента + +- Повторный quality-gate прошли все три статьи: 7 718 / 8 088 / 8 729 знаков основного текста, по одной схеме, таблице и трём блокам кода. +- Независимо проверены первые абзацы, источник jQuery API для делегирования и namespace событий, а также граница клиентского замка Ajax-формы. Валидное `typeof value === 'undefined'` внутри примера кода не считается артефактом генерации. +- Модуль экспортирует ровно три revision без побочного вывода при import; production build после интеграции прошёл. diff --git a/editorial/reviews/TEMPLATE.md b/editorial/reviews/TEMPLATE.md new file mode 100644 index 0000000..5d9ec32 --- /dev/null +++ b/editorial/reviews/TEMPLATE.md @@ -0,0 +1,58 @@ +# [Период] — ручное редакционное ревью + +Партия: + +- `[slug 1]` +- `[slug 2]` +- `[slug 3]` + +Дата проверки: YYYY-MM-DD. Редактор: [имя или роль]. + +## 1. Факты и техника + +Для каждой статьи: + +| Статья | Главный тезис | Первичные источники | Версия / ограничение | Пример проверен | +| --- | --- | --- | --- | --- | +| `[slug]` | | | | да / нет | + +- [ ] Нет непроверенных цифр, фальшивого опыта и универсальных обещаний. +- [ ] Код, команда, SQL или конфигурация соответствуют описанному сценарию. +- [ ] Известные ограничения стоят рядом с решением, а не спрятаны в финале. + +Вердикт: пройти / вернуть в доработку. Причины: + +## 2. Редактура, голос и объём + +Для каждой статьи: + +| Статья | Знаки основного текста | Симптом в начале | Период голоса | Новое умение автора | Шаблонные фразы удалены | +| --- | ---: | --- | --- | --- | --- | +| `[slug]` | | да / нет | | | да / нет | + +- [ ] Основной текст — от 5 000 до 15 000 знаков без источников. +- [ ] Речь следует схеме «симптом → причина → проверка → действие». +- [ ] Каждый абзац добавляет технический факт, решение, ограничение или следующий шаг. +- [ ] Нет анахронизмов относительно `editorial/voice/author-trajectory-2017-2027.md`. + +Вердикт: пройти / вернуть в доработку. Причины: + +## 3. Визуал и выпуск + +Для каждой статьи: + +| Статья | Visual asset и назначение | `alt` и подпись | Таблица | Мобильная проверка | Сборка | +| --- | --- | --- | --- | --- | --- | +| `[slug]` | | да / нет | да / нет | 375px: да / нет | да / нет | + +- [ ] Схема, иллюстрация или график объясняет часть материала, а не заполняет место. +- [ ] Таблица остаётся читаемой или прокручивается внутри контейнера на узком экране. +- [ ] Запущены `node --check`, XML-проверка SVG (если есть), `npm run audit:articles -- ` и `npm run build`. + +Вердикт: пройти / вернуть в доработку. Причины: + +## Итог + +Статус: готово к интеграции / вернуть автору. + +Изменения после ревью: diff --git a/editorial/voice/author-trajectory-2017-2027.md b/editorial/voice/author-trajectory-2017-2027.md new file mode 100644 index 0000000..5e3736a --- /dev/null +++ b/editorial/voice/author-trajectory-2017-2027.md @@ -0,0 +1,279 @@ +# Траектория голоса автора: 2017–2027 + +Это редакторская карта для продолжения архива. Она описывает не идеального +«технического автора вообще», а наблюдаемую эволюцию DarkRiDDeR: от +практика, который делится найденным решением, к инженеру, способному объяснить +границы системы и выбор команды. + +Основание карты — 13 исходных публикаций 2017–2019 годов: рецепты по +Bitrix/PHP и Windows, заметки о D, а также материалы о Webpack и jQuery. +Интервью, переводы и пересказы конференционных докладов важны для тематического +круга автора, но не являются чистым образцом его фразировки. Голос автора в них +лучше искать в заголовке, подводке, выборе примера, пояснениях и практическом +выводе. + +## 1. Исходный язык 2017 года + +Автор начинает с предмета, а не с рассуждения о его важности. Заголовок обычно +называет стек и операцию: «Bitrix API. Функция для генерации кода элемента…», +«Ошибка PHP. SSL certificate error…», «Компиляция 64-x разрядных программ…». +Первый абзац быстро даёт знакомую ситуацию: «Часто в Bitrix необходимо…», +«Недавно столкнулся с такой проблемой…», «При выполнении… может возникнуть +ошибка». + +Базовая интонация — доброжелательный коллега рядом с рабочим столом. Он не +строит безличную лекцию, а ведёт читателя по найденному пути: «Для начала +нужно…», «Давайте…», «Создадим…», «Открываем командную строку, пишем…». +После инструкции автор обычно называет ожидаемый результат: «После чего ошибка +должна быть решена», «Если всё прошло удачно…», «В итоге у нас получается…». +Финал осторожный и человеческий: «Возможно, в вашем случае…», «Надеюсь, что +помог», «Поздравляю». + +| Наблюдаемый паттерн | Зачем он нужен | Редакторская форма | +| --- | --- | --- | +| Стек + конкретная операция в заголовке | Сразу ограничивает задачу | Bitrix API. Проверка символьного кода перед сохранением | +| Симптом до рецепта | Читатель узнаёт свой случай | Форма возвращает ID, но изображение не привязывается к товару. | +| Последовательность «для начала → действие → результат» | Делает текст выполнимым | Один шаг, команда или фрагмент кода, затем ожидаемый эффект | +| Термин и расшифровка в скобках | Автор не предполагает лишнего опыта | entry-файл (точка входа сборки) при первом упоминании | +| Осторожный вывод | Не выдаёт локальную находку за закон | Этот путь подходит, если проблема находится именно в… | + +Синтаксис исходного автора не академический. В нём есть длинные объяснения, +скобки с расшифровками, разговорные переходы и иногда шероховатости. Их не надо +копировать: орфографическая ошибка, калька, устаревшее техническое утверждение +или лишняя эмоциональность не являются частью голоса. Сохраняется другое: +близость к реальному действию, прямой порядок шагов и понятный критерий +готовности. + +Постоянная формула автора на всём промежутке: + +> симптом → граница проблемы → проверка → действие → ожидаемый результат → ограничение + +В 2017 году граница чаще всего локальна: конкретный API-вызов, настройка +Windows, браузер, файл конфигурации или форма Bitrix. Позже формула остаётся, +но граница постепенно расширяется до модуля, сервиса, потока данных и решения +команды. + +## 2. Развитие по периодам + +### 2017–2018: практик интеграций и среды разработки + +**T-shape.** Вертикаль — PHP/Bitrix: инфоблоки, свойства, файлы, торговые +предложения, ошибки интеграции. Ширина — D, Windows-инструменты, браузер, +базовый JavaScript и первая фронтенд-сборка. Автор уверенно показывает +выполнимый фрагмент, но ещё не обобщает его до архитектурного правила. + +**Допустимый словарь.** инфоблок, торговое предложение, +символьный код, cURL, CA bundle, +timeout, SDK, linker, entry, +bundle, jQuery, «глобальная переменная». Английский +термин поясняется, если он не виден из кода. Слова «контракт», «граница +ответственности» и «жизненный цикл» возможны, когда они привязаны к конкретным +полям или вызовам, а не заменяют объяснение. + +**Синтаксис и тон.** Короткий симптом, затем нумерованный маршрут или код. +Допустимы «давайте», «проверим», «в моём случае», но без заигрывания с +читателем. Сначала действие, затем обоснование. Одно предложение не должно +одновременно объяснять API, историю платформы и решение. + +**Виды доказательств.** Воспроизводимый фрагмент кода, текст ошибки, снимок +экрана, команда, файл конфигурации, ручная проверка результата, ссылка на +документацию API. Достаточно локального случая, если автор явно называет его +границы. + +**Чего автор ещё не знает.** Он не пишет от лица человека, который строил +SLO, проводил разборы крупных инцидентов, внедрял распределённую трассировку, +проектировал организационные процессы или владеет экономикой платформы. Нельзя +ретроспективно добавлять ему зрелую практику threat modeling, Kubernetes, +feature flags и продуктовые метрики без отдельного, правдоподобного мостика. + +### 2019–2021: инженер на стыке фронтенда, доставки и данных + +**T-shape.** PHP/интеграции остаются вертикалью, но к ним добавляются +модульный JavaScript, Webpack, HTTP, контейнеризация, SQL и первые +воспроизводимые сценарии доставки. Автор уже видит, что ошибка рождается на +границе модулей, конфигураций и окружений. + +**Допустимый словарь.** ES-модуль, dependency graph, +source map, «кеш-заголовок», Dockerfile, «образ», +«миграция», «индекс», EXPLAIN, pipeline, +rollback. Термины CI/CD и «наблюдаемость» допустимы, +но каждый раз должны быть разложены на конкретный запуск, лог, метрику или +проверку. + +**Синтаксис и тон.** Появляется спокойное разделение условий: «если плагин +читает window.jQuery…», «если контейнер стартует от +непривилегированного пользователя…». Автор всё ещё может говорить от первого +лица, но реже использует ободряющие финалы и чаще фиксирует предпосылку, версию +и побочный эффект. + +**Виды доказательств.** Конфигурация до/после, размер bundle, сетевой запрос, +вывод сборки, SQL-план, контейнерный лог, тестовый запрос, документированный +rollback. Метрика допустима, когда известны источник, окно измерения и +сравниваемый вариант. + +**Чего автор ещё не знает.** Нельзя изображать опыт руководителя большой +платформы, владельца многооблачных расходов или человека с многолетней +практикой incident command. Сложные распределённые схемы допустимы как +изучаемый предмет, но не как безапелляционный личный опыт. + +### 2022–2024: системный практик + +**T-shape.** Центр тяжести смещается от отдельного рецепта к надёжности +изменения: производительность, доступность, безопасность, тестирование, +релизы, данные и согласование ролей. Глубина остаётся технической — автор не +уходит в абстрактное управление. + +**Допустимый словарь.** p95, «бюджет ошибок», trace, +span, «корреляционный идентификатор», rate limit, +threat model, CSP, WCAG, «контракт API», +«идемпотентность», «канареечный релиз», «откат». Эти слова нельзя ставить +списком: рядом нужны единица измерения, граница ответственности или конкретный +сценарий отказа. + +**Синтаксис и тон.** Автор пишет короче и точнее. Вместо «система стала +быстрее» — «p95 ответа снизился с 1,8 до 0,7 с на тестовом наборе из N +запросов». Вместо «следует учесть безопасность» — условие атаки, защитный +контроль и способ проверки. Появляются таблицы вариантов и отдельные абзацы +про цену решения. + +**Виды доказательств.** Трасса, график, нагрузочный сценарий, результат +автотеста, матрица прав, модель угроз, чек-лист релиза, план отката. Личный +опыт отделяется от данных из внешней документации. + +**Чего автор ещё не знает.** Он ещё не обязан писать стратегию компании, +правила закупок или универсальную организационную модель. Не стоит приписывать +ему неизмеренный опыт внедрения AI-практик на масштабе всей организации. + +### 2025–2027: наставник и техлид + +**T-shape.** Автор связывает глубину разработки с последствиями для команды: +границы сервисов, владение, стоимость сопровождения, надёжная доставка, +наблюдаемость и безопасное использование новых инструментов. Он объясняет не +только «как исправить», но и почему выбран именно этот компромисс. + +**Допустимый словарь.** ADR, owner, SLO, +«стоимость владения», blast radius, «схема миграции», «контроль +деградации», «eval-набор», human review, «политика данных». +Термины из AI, платформенной инженерии и безопасности допустимы только при +технической привязке: входные данные, риск, метрика, контроль и владелец. + +**Синтаксис и тон.** Тон спокойный, наставнический и лишён позы. Статья +сравнивает два-три варианта, называет цену каждого и оставляет короткий путь +внедрения. Первое лицо используется для наблюдения из практики, а не как +замена доказательству. Закрытие — не вдохновляющий манифест, а решение, +ограничение и следующий проверяемый шаг. + +**Виды доказательств.** Матрица выбора, архитектурная схема, измерение до/после, +последствия инцидента без чувствительных деталей, прогон тестов, план +мониторинга и отката, ADR или иной зафиксированный контекст решения. Если +данные нельзя раскрыть, автор честно описывает метод и не придумывает цифры. + +**Чего автор всё ещё не знает.** Десять лет публикаций не дают права +утверждать, что его путь универсален. Автор не делает прогнозов за всю отрасль, +не приписывает команде непроверенные результаты и не заменяет технический +анализ модными словами. + +## 3. Как звучит прагматичная техническая речь + +Плохая фраза обычно скрывает объект, условие или проверку. Хорошая называет их +прямо. + +| Период | Плохо | Хорошо | +| --- | --- | --- | +| 2017–2018 | «Нужно грамотно создавать торговые предложения, иначе будут проблемы.» | «CIBlockElement::Add вернул ID, но предложение ещё не связано с товаром. После сохранения проверяем свойство связи и выборку каталога.» | +| 2019–2021 | «Webpack магически подключает jQuery во всём проекте.» | «Старый плагин читает window.jQuery. Сначала кладём импорт в window, затем подключаем плагин; одного ProvidePlugin для этого случая недостаточно.» | +| 2022–2024 | «Наблюдаемость помогла оптимизировать сервис.» | «Трасса показала 1,1 с ожидания в запросе к партнёру. Ограничили timeout и добавили отдельную метрику ошибок этого вызова.» | +| 2025–2027 | «Надо внедрить AI и Kubernetes по современным стандартам.» | «Для автосуммаризации используем только обезличенный вход. До релиза сравниваем ответы на фиксированном eval-наборе, а спорные случаи оставляем на human review.» | + +Короткая техническая речь не означает телеграфный стиль. Контекст нужен, если +без него нельзя повторить решение. Лишним считается предложение, которое не +добавляет симптом, причину, проверку, действие, ограничение или результат. + +## 4. Непрерывность эволюции + +Во все годы сохраняются пять признаков автора: + +1. Тема начинается с конкретной инженерной работы, а не с тренда. +2. Термин привязан к коду, конфигурации или наблюдаемому эффекту. +3. Читателю дают выполнимый следующий шаг. +4. Ограничение называют рядом с решением, а не мелким шрифтом в конце. +5. Заключение возвращает к исходному симптому и критерию проверки. + +Меняется глубина этого же движения: + +| Этап | Что расширяется | Как это проявляется в тексте | +| --- | --- | --- | +| 2017–2018 | Локальная операция | Код, команда, настройка, ручная проверка | +| 2019–2021 | Граница модулей и окружений | Конфигурация, порядок загрузки, сборка, данные | +| 2022–2024 | Поведение системы после изменения | Метрики, тесты, риски, релиз и откат | +| 2025–2027 | Последствия решения для команды | Варианты, стоимость, владелец, способ повторить решение | + +Новая компетенция должна появляться как ответ на предыдущую проблему. Например, +после заметок о timeout естественна статья о границах ожидания между браузером, +прокси и сервисом; после сборки фронтенда — материал о размере bundle или +кешировании; после ручной диагностики — наблюдаемость и автоматическая +проверка. Ненормален скачок от рецепта Bitrix сразу к «корпоративной AI-стратегии» +без цепочки практических задач между ними. + +## 5. Обязательные критерии редактора + +Редактор считает текст эволюцией автора, а не внезапной статьёй автора 2027 +года, только если выполнены все условия ниже. + +1. **Временная честность.** Словарь и уровень уверенности соответствуют году. + В статье 2017 года нет нераскрытых SLO, + OpenTelemetry, Kubernetes, LLM eval + и других поздних рамок. В статье 2027 года они объяснены через технический + сценарий, а не используются как декорация. +2. **Преемственность темы.** Новая область вырастает из уже освоенной: CMS и + PHP → фронтенд/сборка → доставка и данные → надёжность и архитектурные + решения. Если мост не очевиден, его нужно назвать во вступлении. +3. **Соразмерное доказательство.** Ранний локальный рецепт подтверждается + кодом и ручной проверкой; поздний системный вывод — измерением, тестом, + схемой, сравнением вариантов или документом решения. +4. **Сохранённая оптика практики.** Даже поздний текст начинает с конкретного + сбоя, ограничения или вопроса реализации. Статья, начинающаяся с + «в современном мире» или с общего манифеста, не проходит. +5. **Постепенное усложнение синтаксиса.** Ранний текст ведёт читателя шагами; + поздний может сравнивать варианты, но не прячет действие за абстрактными + существительными. +6. **Ограниченная компетентность.** Автор называет неизвестное, зависимость от + версии, нагрузки, прав, данных или команды. Уверенный тон не заменяет + границы применимости. +7. **Непридуманный опыт.** Число, инцидент, команда и результат либо имеют + источник, либо описаны как учебный пример. Нельзя фабриковать + «сэкономили 40%» или «внедрили во всей компании». +8. **Практический артефакт.** Есть минимальный путь проверки: код, запрос, + конфигурация, таблица симптомов, схема, тест или измерение. Совет без + артефакта не соответствует исходной манере. +9. **Проверяемый финал.** В конце есть ожидаемый эффект и следующий шаг, а не + лозунг, рекламный призыв или универсальное обещание. + +Статья не проходит редактуру, если содержит три или более признака +«внезапного 2027 года»: непояснённый современный жаргон, абстрактный +стратегический тон, метрики без метода, универсальные выводы, отсутствие +выполнимого шага или компетенции, не связанные с предыдущими периодами. + +## 6. Три прохода редактора голоса + +Эта карта дополняет общий стандарт качества и не заменяет техническое, +фактологическое и визуальное ревью. + +1. **Временной проход.** Отметить год статьи, разрешённый словарь и одно новое + умение. Проверить, что оно следует из предыдущей траектории. +2. **Голосовой проход.** Найти симптом в начале, конкретный артефакт в середине + и проверяемый финал. Убрать кальки, общие оценки и фразы, в которых + существительные скрывают действие. +3. **Прагматический проход.** Для каждого абзаца ответить: что читатель теперь + может проверить или сделать? Если ответа нет, сократить, перенести или + заменить абзац фактом. + +Короткая карточка решения для редактора: + + Год и период: + Постоянные признаки голоса: + Новое умение и мост к нему: + Артефакт доказательства: + Оговорка или граница: + Анахронизмы, которые были удалены: + Вердикт: соответствует / вернуть в доработку diff --git a/web/app/globals.css b/web/app/globals.css index 6a3391a..1522302 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -312,4 +312,13 @@ h3 { .article-content { font-size: 16px; } + + /* Документация Bitrix и имена API часто содержат длинные неразрывные токены. */ + .article-hero h1, + .article-content h2, + .article-content h3, + .article-content :not(pre) > code, + .article-content a { + overflow-wrap: anywhere; + } } diff --git a/web/data/articles.json b/web/data/articles.json index 7837f89..5f2fbe5 100644 --- a/web/data/articles.json +++ b/web/data/articles.json @@ -4863,48 +4863,48 @@ }, { "slug": "editorial-2018-04-field-bitrix-slugs", - "title": "Bitrix API. Символьные коды и URL: разбор типичной ошибки", + "title": "Bitrix API. Карточка открывает не тот товар: проверяем конфликт CODE", "date": "2018-04-25T10:00:00+00:00", "author": "DarkRiDDeR", "categories": [ "Bitrix", "PHP", - "SEO" + "Диагностика" ], - "cover": "/assets/illustrations/bitrix-photo-editor.svg", - "excerpt": "Кейс о том, как два похожих товара пытаются занять один URL и ломают ссылку из каталога. В конце — последовательность проверки и критерий готовности.", - "contentHtml": "

На реальном проекте эта история обычно начинается спокойно, а затем всплывает один неприятный крайний случай. Разберём «символьные коды и URL». Типичная ситуация выглядит так: два похожих товара пытаются занять один URL и ломают ссылку из каталога. В такой момент легко срочно поправить видимый симптом, но полезнее пройти короткое расследование и оставить после него защиту для следующего раза.

\n

Последовательность разбора

\n
  1. Собрать симптомы до изменения конфигурации или кода.
  2. Проверить гипотезу самым маленьким безопасным экспериментом.
  3. Исправить причину, а не только видимый эффект.
  4. Добавить защиту или наблюдение, чтобы случай не вернулся незаметно.
\n

Минимальное доказательство

\n

Нам не нужна идеальная модель всей системы. Достаточно такого эксперимента, который отделяет одну гипотезу от другой: повторить запрос, сравнить входы, посмотреть контекст операции или воспроизвести проблему на отдельной записи. Получить читаемый и уникальный код элемента из пользовательского названия.

\n
$required = ['IBLOCK_ID', 'NAME'];\nforeach ($required as $field) {\n    if (empty($fields[$field])) {\n        throw new InvalidArgumentException($field . ' is required');\n    }\n}
\n

Что меняется после исправления

\n

Исправление считается законченным, когда новый путь проверяется автоматически или наблюдается по явному сигналу. Иначе «символьные коды и URL» вернётся в следующем релизе под другим именем. Транслитерация даёт основу, а уникальность и нормализация делают адрес устойчивым.

\n

Материалы для проверки

\n

Если держать этот порядок, решение остаётся понятным и через несколько месяцев.

", - "readingMinutes": 4 + "cover": "/assets/editorial/2018/bitrix-slug-conflict-2018.svg", + "excerpt": "Полевой разбор ситуации, когда адрес детали показывает другой элемент: считаем совпадения по CODE, сравниваем переменную ЧПУ с фильтром и меняем данные без потери следов.", + "contentHtml": "

Есть неприятная ошибка, которую легко принять за кеш: открываешь карточку товара, а видишь другой товар с похожим названием. Особенно странно это выглядит после импорта — обе записи есть в админке, у обеих нормальные картинки, а URL одной вдруг показывает соседнюю. Здесь не нужно начинать с очистки кеша. Главный вопрос: как доказать конфликт CODE или широкий фильтр до того, как менять данные?

\n

Первое правило — не смотреть только на название. Компонент получает строку из адреса и строит по ней выборку. Если выборка возвращает несколько элементов, значение «первого» зависит от порядка и условий запроса. Если она не возвращает ничего, компонент может отдать 404 или подставить другую ветку своей логики. Поэтому нам нужны три наблюдаемых факта: что было в URL, какую переменную получил компонент и сколько записей удовлетворяют его фильтру.

\n

Не путать симптом и причину

\n

Похожее название не доказывает конфликт. В одном каталоге может быть несколько позиций «Classic 250 г» в разных разделах, и тогда адрес обязан содержать достаточный контекст. Наоборот, разные названия могут получить одинаковый код после нормализации. Диагностику начинаю с конкретного сломанного адреса и ID товара, который ожидали увидеть. Только потом читаю список элементов по фактическому ELEMENT_CODE.

\n
\"Дерево
Сначала считаем набор совпадений. Кеш проверяем только после пути и данных.
\n

Какие данные собрать до исправления

\n
ФактЗачем он нуженКак зафиксировать
Исходный URLПоказывает, что реально запросил браузерСохранить полный путь из адресной строки или access-лога
Ожидаемый IDНе даёт спорить о том, какая запись считается правильнойВзять ID из админки или из результата импорта
ELEMENT_CODEСвязывает путь с данными компонентаВывести переменную после разбора ЧПУ на тестовом стенде
Все записи по CODEОтличает один результат от конфликтаСделать ограниченный GetList в том же инфоблоке
Фильтр деталиОбъясняет, почему часть записей исключена или выбранаСверить с параметрами и кодом конкретного компонента
\n

Контрольная выборка

\n

Документация CIBlockElement::GetList позволяет явно задать сортировку, фильтр, ограничение и набор полей. Для диагностики беру только те поля, которые помогают отличить записи: ID, имя, CODE, основной раздел и шаблон детального URL. Запрос не должен случайно тянуть свойства всего каталога: его задача — показать размер набора и порядок элементов.

\n
<?php\n\nfunction findActiveElementsByCode($iblockId, $code)\n{\n    $result = CIBlockElement::GetList(\n        array("ID" => "ASC"),\n        array(\n            "IBLOCK_ID" => (int)$iblockId,\n            "=CODE" => $code,\n            "ACTIVE" => "Y",\n        ),\n        false,\n        array("nTopCount" => 20),\n        array("ID", "NAME", "CODE", "IBLOCK_SECTION_ID", "DETAIL_PAGE_URL")\n    );\n\n    $items = array();\n    while ($item = $result->GetNext()) {\n        $items[] = $item;\n    }\n\n    return $items;\n}\n\n$items = findActiveElementsByCode(12, "classic-250-g");\nif (count($items) !== 1) {\n    throw new RuntimeException("Нужно разобрать " . count($items) . " совпадений");\n}
\n

Сортировка по ID в этом примере нужна не для выбора «правильного» товара, а для повторяемого вывода. Если там два элемента, проблема уже доказана: детальный компонент не должен случайно решать, что меньший ID важнее. Дальше либо сужаем фильтр контекстом раздела, либо исправляем один из кодов по заранее выбранному правилу.

\n

Как отличить три разных случая

\n
Результат проверкиЧто это значитСледующий шаг
0 совпаденийURL разобран, но элемент не проходит базовый фильтрПроверить значение переменной, активность, инфоблок и шаблон ссылки
1 совпадение, ID правильныйДанные и базовый фильтр совпалиСравнить дополнительные условия компонента и только затем кеш
1 совпадение, ID другойПеременная из URL не соответствует ожидаемому товаруПроверить генерацию URL, шаблон и исходный CODE элемента
2 и более совпаденийФильтр недостаточно точный или коды конфликтуютРешить, нужен ли контекст раздела, затем изменить конфликтующие данные
\n

Проверяем, что компонент получил из URL

\n

Не нужно угадать имя переменной по шаблону. Комплексный компонент разбирает путь через CComponentEngine::ParseComponentPath и возвращает переменные, восстановленные из маркеров. Для проблемной ссылки полезно на тестовой копии вывести $page и $arVariables. Так видно, не потерялся ли раздел и действительно ли ELEMENT_CODE равен строке из адреса.

\n
<?php\n\n$templates = array(\n    "detail" => "#SECTION_CODE#/#ELEMENT_CODE#/",\n);\n$variables = array();\n$page = CComponentEngine::ParseComponentPath(\n    "/catalog/",\n    $templates,\n    $variables,\n    "/catalog/kofe/classic-250-g/"\n);\n\nif ($page !== "detail") {\n    throw new RuntimeException("Не найден шаблон detail");\n}\n\nerror_log(print_r($variables, true));
\n

Если в массиве нет SECTION_CODE, а детальный запрос должен учитывать раздел, коды элементов могут быть вполне корректны. Ошибка будет в URL-шаблоне или в логике компонента, который не применяет восстановленную переменную. И наоборот: если переменные верны, а GetList возвращает несколько записей, искать надо в данных и условиях выборки, не в роутинге.

\n

Исправление без потери истории

\n

Когда конфликт подтверждён, сначала выбираю правило для нового адреса: суффикс, артикул или раздел. Затем сохраняю старый URL и список мест, которые на него ссылаются. Смена CODE меняет адрес, поэтому публикацию лучше выполнять отдельным шагом с проверкой ссылок. Метод CIBlockElement::Update возвращает результат изменения; при ошибке не пропускаем LAST_ERROR.

\n
<?php\n\n$element = new CIBlockElement();\n$updated = $element->Update($duplicateId, array(\n    "CODE" => "classic-250-g-2",\n));\n\nif (!$updated) {\n    throw new RuntimeException($element->LAST_ERROR);\n}\n\n// После изменения снова выполняем findActiveElementsByCode().
\n

Порядок работы в продовой задаче

\n
  1. Зафиксировать URL, ожидаемый ID и время, когда ошибка наблюдалась.
  2. На тестовой копии получить переменные, восстановленные из того же пути.
  3. Сделать выборку по фактическому CODE в нужном IBLOCK_ID и посчитать результаты.
  4. Сравнить полученные ID с тем, что показывает детальный компонент после его дополнительных фильтров.
  5. Если есть конфликт, выбрать новое стабильное правило кода и проверить все старые ссылки, которые важны для проекта.
  6. После изменения повторить URL-проверку. Кеш и индекс обновлять только по принятому в проекте порядку, когда данные и маршрут уже доказаны.
\n

Ограничения

\n

Эта заметка не утверждает, что любое совпадение CODE ошибочно. В некоторых каталогах один и тот же код допустим в разных витринах или разделах, и тогда адрес и фильтр обязаны включать этот контекст. Не следует добавлять раздел в запрос автоматически: сначала нужно понять, что именно считает идентичностью текущий компонент.

\n

Также не стоит менять десятки кодов одной SQL-командой. У Bitrix есть API изменения элемента, обработчики событий и проектные зависимости от адресов. Сначала правим один доказанный конфликт на тестовых данных, проверяем маршрут и только потом составляем отдельный план для массовой миграции.

\n

Итог

\n

Когда адрес открывает не тот товар, удобнее не спорить о кеше, а посчитать факты. URL даёт переменную, переменная даёт набор элементов, набор показывает — это маршрут, фильтр или конфликт данных. После такой проверки изменение CODE становится осознанной операцией, а не попыткой наугад исправить карточку.

\n

Проверяемые источники

\n", + "readingMinutes": 11 }, { "slug": "editorial-2018-04-mechanism-bitrix-slugs", - "title": "Bitrix API. Почему важна тема: Символьные коды и URL", + "title": "Bitrix API. Как адрес каталога превращается в ELEMENT_CODE", "date": "2018-04-15T10:00:00+00:00", "author": "DarkRiDDeR", "categories": [ "Bitrix", "PHP", - "SEO" + "ЧПУ" ], - "cover": "/assets/illustrations/bitrix-photo-editor.svg", - "excerpt": "Разбираем, почему транслитерация даёт основу, а уникальность и нормализация делают адрес устойчивым — и какие ошибки возникают, если этот механизм не учитывать.", - "contentHtml": "

Сначала хотелось просто применить готовый рецепт, но без понимания механизма он быстро превращается в набор случайных действий. Поэтому давайте разберём «символьные коды и URL». Транслитерация даёт основу, а уникальность и нормализация делают адрес устойчивым. Когда этот слой остаётся невидимым, команда начинает лечить следствие: добавляет таймаут, глобальную переменную, второй кеш или ещё одну повторную попытку.

\n

Модель происходящего

\n

Для начала полезно назвать владельца состояния, момент изменения и границу, за которую действие не должно протекать незаметно. Тогда можно отличить нормальную задержку от отказа, локальную оптимизацию от нарушения контракта и временный обход от постоянного решения.

\n
$id = $element->Add($fields);\nif ($id === false) {\n    error_log('Bitrix error: ' . $element->LAST_ERROR);\n    return null;\n}\nreturn (int) $id;
\n

Как проверить модель на практике

\n
  1. Назвать границу, на которой действует механизм.
  2. Зафиксировать, что считается успехом и отказом.
  3. Проверить, какие данные или ресурсы остаются после ошибки.
  4. Добавить измерение, которое подтвердит вывод в следующем проекте.
\n

Ограничения

\n

У этой модели нет магической силы: два похожих товара пытаются занять один URL и ломают ссылку из каталога. Поэтому в рабочем проекте нужно добавлять наблюдение, разумные лимиты и понятный путь отката. Чем дороже ошибка, тем важнее заранее проговорить этот случай.

\n

Материалы для проверки

\n

Если держать этот порядок, решение остаётся понятным и через несколько месяцев.

", - "readingMinutes": 3 + "cover": "/assets/editorial/2018/bitrix-slug-route-2018.svg", + "excerpt": "Разбираем, где ЧПУ-путь становится переменной компонента, почему URL-шаблон не равен запросу к инфоблоку и как проверить связку без гадания по кешу.", + "contentHtml": "

Иногда символьный код в элементе правильный, а карточка всё равно отвечает 404. В другой раз тот же код работает только без раздела в адресе. Причина обычно не в транслите: путь сначала разбирает компонент, а уже потом его переменные попадают в фильтр инфоблока. Разберём один вопрос: что должно совпасть, чтобы адрес каталога действительно стал значением ELEMENT_CODE?

\n

Это полезно отделить в голове. Адрес /catalog/kofe/classic-250-g/ не является запросом к таблице элементов. Для комплексного компонента Bitrix сначала определяет, какой шаблон пути подошёл, и восстанавливает переменные из URL. Только затем код компонента решает, как искать элемент. Если смешать эти два шага, начинается бесконечная правка CODE, хотя ошибка сидит в шаблоне или в имени переменной.

\n

Что делает движок ЧПУ

\n

В документации CComponentEngine::ParseComponentPath описано, что метод получает папку ЧПУ, массив шаблонов и текущий путь. Он возвращает код найденного шаблона, а переменные из пути записывает в переданный массив. Если шаблон не найден, результат — пустая строка. Значит, до запроса к инфоблоку можно и нужно посмотреть две вещи: какой шаблон распознан и какое значение оказалось в ELEMENT_CODE.

\n

Шаблон пишется относительно папки компонента. Например, для папки /catalog/ внутри массива нужен путь #SECTION_CODE#/#ELEMENT_CODE#/, а не полный адрес с начальным слешем. Это не вкусовщина: документация отдельно предупреждает, что лишний слеш в шаблоне меняет результат разбора.

\n
\"Схема:
Переменная из URL и элемент инфоблока живут на разных шагах. Между ними стоит проектный фильтр компонента.
\n

Четыре значения, которые должны совпасть

\n
УчастокПримерКак проверить
Папка ЧПУ/catalog/Сравнить с SEF_FOLDER вызванного компонента
Шаблон детали#SECTION_CODE#/#ELEMENT_CODE#/Проверить отсутствие лишнего начального слеша и нужные маркеры
ПеременнаяELEMENT_CODE = classic-250-gВывести массив, полученный после разбора, на тестовой среде
ВыборкаIBLOCK_ID + CODE + ACTIVEСравнить фильтр компонента с контрольным GetList
Ссылка в шаблонеТот же набор маркеровСобрать URL из значений и открыть его вручную
\n

Минимальный воспроизводимый разбор

\n

Ниже не готовый комплексный компонент, а короткая проверка его основания. Запускаю её на тестовой странице с известным путём. Если $page не равен detail, до запроса к инфоблоку дело вообще не дошло. Если код страницы найден, но ELEMENT_CODE пуст, виноват шаблон или сам адрес.

\n
<?php\n\nCModule::IncludeModule("iblock");\n\n$arUrlTemplates = array(\n    "detail" => "#SECTION_CODE#/#ELEMENT_CODE#/",\n);\n$arVariables = array();\n\n$page = CComponentEngine::ParseComponentPath(\n    "/catalog/",\n    $arUrlTemplates,\n    $arVariables,\n    "/catalog/kofe/classic-250-g/"\n);\n\nif ($page !== "detail" || empty($arVariables["ELEMENT_CODE"])) {\n    throw new RuntimeException("URL не разобран как детальная страница");\n}\n\n$result = CIBlockElement::GetList(\n    array(),\n    array(\n        "IBLOCK_ID" => 12,\n        "=CODE" => $arVariables["ELEMENT_CODE"],\n        "ACTIVE" => "Y",\n    ),\n    false,\n    array("nTopCount" => 1),\n    array("ID", "NAME", "CODE")\n);\n\n$element = $result->GetNext();\nif (!$element) {\n    throw new RuntimeException("URL разобран, но элемент не найден");\n}
\n

В примере я специально оставил фильтр небольшим. Реальный каталог может добавить раздел, права, цену, наличие или свойство витрины. Эти условия нельзя угадывать из адреса. Их нужно взять из конкретного компонента и применить в контрольной выборке. Иначе тест будет доказывать только то, что элемент вообще существует, а не то, что его видит пользователь.

\n

Почему генерация и разбор должны пользоваться одной формой адреса

\n

Метод CComponentEngine::MakePathFromTemplate подставляет значения массива в маркеры шаблона. Это удобная точка для проверки обратного направления: у нас есть SECTION_CODE и ELEMENT_CODE, собираем путь и затем разбираем его тем же шаблоном. Если после такого круга переменная изменилась или пропала, в коде сайта уже есть расхождение.

\n
<?php\n\n$url = CComponentEngine::MakePathFromTemplate(\n    "#SECTION_CODE#/#ELEMENT_CODE#/",\n    array(\n        "SECTION_CODE" => "kofe",\n        "ELEMENT_CODE" => "classic-250-g",\n    )\n);\n\n// $url: kofe/classic-250-g/\n// Для ссылки добавляем папку /catalog/ в одном месте проекта.
\n

Последовательность от ссылки до карточки

\n
  1. Взять реальный адрес, который не открывается, и сохранить его без ручной правки.
  2. Сверить папку и шаблон детали в параметрах вызванного компонента.
  3. На тестовой среде вывести код страницы и массив переменных после ParseComponentPath.
  4. Передать полученный ELEMENT_CODE в короткий CIBlockElement::GetList с теми же базовыми фильтрами.
  5. Если элемент найден, сравнить с фильтром самого компонента: раздел, активность, права и проектные свойства.
  6. Собрать обратную ссылку из тех же маркеров и повторить проверку после изменения шаблона.
\n

Частые расхождения

\n
СимптомГде искатьБезопасная проверка
Страница не определяетсяПапка ЧПУ или шаблон деталиПроверить результат ParseComponentPath до обращения к инфоблоку
Страница определяется, код пустМаркер отличается от имени, которое ждёт компонентСравнить ключи массива переменных с параметрами компонента
Код есть, элемента нетCODE, инфоблок, активность или дополнительный фильтрЗапустить GetList сначала с базовыми, затем с проектными условиями
Ссылка формируется иначе, чем разбираетсяДва разных URL-шаблона в шаблоне и компонентеСобрать путь через MakePathFromTemplate и разобрать его обратно
\n

Ограничения

\n

Эта диагностика начинается в момент, когда PHP-компонент уже получил запрос. Если веб-сервер или правила перенаправления не передали путь в приложение, ParseComponentPath не сможет это исправить. Тогда проверять нужно предыдущий слой: фактический URI, правило маршрутизации и точку входа сайта. Не стоит менять CODE, пока не доказано, что компонент вообще получил нужную переменную.

\n

Ещё одна ловушка — перенос чужого шаблона без понимания его маркеров. В Bitrix можно назвать переменные по-разному, но компонент и его фильтр должны читать то же имя, которое восстановлено из пути. Я бы не делал универсальную функцию для всех страниц сайта: лучше зафиксировать один шаблон рядом с конкретным каталогом и покрыть его двумя-тремя адресами из реальных данных.

\n

Итог

\n

ЧПУ — это не «красивый CODE в базе», а связка из папки, шаблона, восстановленных переменных и фильтра элемента. Когда ссылка ведёт в 404, сначала смотрим результат разбора URL, затем выборку. После такой проверки становится видно, нужна ли правка в данных, компоненте или маршруте.

\n

Проверяемые источники

\n", + "readingMinutes": 10 }, { "slug": "editorial-2018-04-practice-bitrix-slugs", - "title": "Bitrix API. Символьные коды и URL: рабочая схема", + "title": "Bitrix API. Символьный код: как не получить два одинаковых адреса", "date": "2018-04-07T10:00:00+00:00", "author": "DarkRiDDeR", "categories": [ "Bitrix", "PHP", - "SEO" + "Практика" ], - "cover": "/assets/illustrations/bitrix-photo-editor.svg", - "excerpt": "Практическая заметка о том, как получить читаемый и уникальный код элемента из пользовательского названия. С минимальной схемой, проверкой результата и ограничениями.", - "contentHtml": "

Периодически в проекте встречается задача, которая с виду кажется мелкой, а потом съедает полдня. В этот раз разбираюсь с темой «символьные коды и URL». Цель заметки — получить читаемый и уникальный код элемента из пользовательского названия. Не будем начинать с большой переделки: сначала соберём минимальный сценарий, который можно показать коллеге и повторить на чистой среде.

\n

Минимальная рабочая схема

\n

Первое правило здесь простое: отделяем входные данные от побочного эффекта. До того как менять состояние системы, проверяем условия, назначаем понятный идентификатор операции и оставляем достаточно контекста для диагностики. Транслитерация даёт основу, а уникальность и нормализация делают адрес устойчивым.

\n
CModule::IncludeModule('iblock');\n$element = new CIBlockElement();\n$id = $element->Add([\n    'IBLOCK_ID' => 12,\n    'NAME' => $name,\n    'ACTIVE' => 'Y',\n    'PROPERTY_VALUES' => $properties,\n]);\nif (!$id) {\n    throw new RuntimeException($element->LAST_ERROR);\n}
\n

Что проверяем после запуска

\n
  1. Сформулировать вход и ожидаемый результат: получить читаемый и уникальный код элемента из пользовательского названия.
  2. Выполнить минимальный сценарий отдельно от остальной системы.
  3. Проверить отрицательный путь: два похожих товара пытаются занять один URL и ломают ссылку из каталога.
  4. Сохранить наблюдаемый результат в тесте, логе или коротком runbook.
\n

Где чаще всего ошибаются

\n

Опасность темы «символьные коды и URL» не в сложном синтаксисе, а в неявных предположениях. Два похожих товара пытаются занять один URL и ломают ссылку из каталога. Если решение зависит от версии среды, внешней системы или прав пользователя, это лучше проверить отдельным шагом и записать рядом с кодом.

\n

Материалы для проверки

\n

Если держать этот порядок, решение остаётся понятным и через несколько месяцев.

", - "readingMinutes": 3 + "cover": "/assets/editorial/2018/bitrix-slug-build-2018.svg", + "excerpt": "Собираем символьный код элемента из имени, проверяем занятость в нужном инфоблоке и разбираем границу, за которой простой суффикс перестаёт быть защитой.", + "contentHtml": "

Добавляем товар в Bitrix и берём CODE из названия. На тесте всё выглядит хорошо. Потом менеджер заводит «Кофе Classic 250 г» второй раз — с запятой или лишним пробелом. После транслитерации получается тот же адрес, а ссылка из каталога ведёт к записи, которую никто не собирался открывать. Главный вопрос этой заметки простой: как получить читаемый код и не принять совпадение за успех?

\n

Сначала важная оговорка. Транслитерация не выбирает свободный URL. Она преобразует строку по заданным правилам. Уникальность — уже правило конкретного инфоблока и конкретного способа создания элементов. Поэтому проверяем не «красиво ли выглядит код», а есть ли другой элемент с тем же значением там, где его будет искать каталог.

\n

Что даёт системный транслит

\n

В Bitrix для этой задачи есть CUtil::translit. Метод принимает строку, язык и набор параметров. В нём можно задать регистр, замену пробелов и прочих символов, ограничение длины, а также удаление повторяющихся замен. Для адреса каталога мне удобнее дефис и нижний регистр: в результате не приходится отдельно объяснять, почему одни карточки имеют подчёркивание, а другие — дефис.

\n

Но нормализация не делает два разных названия разными. «Кофе Classic 250 г», «Кофе Classic-250 г» и «Кофе Classic 250 г» вполне могут прийти к одному кандидату. Это не ошибка CUtil::translit. Функция честно выполнила свою работу: привела вход к одному виду. Сравнивать и разрешать конфликт должен вызывающий код.

\n
\"Схема
Транслит формирует кандидата. Решение о свободном коде появляется только после проверки в нужном инфоблоке.
\n

Минимальный контракт

\n

Для одного каталога достаточно договориться о нескольких вещах до написания функции. Они не привязаны к шаблону страницы и не требуют большой переделки. Зато по ним сразу видно, почему повторный импорт изменил адрес или почему карточка попала не в тот раздел.

\n
ШагЧто считаем результатомЧто проверяем
ИмяЕсть непустое названиеНе передаём в транслит пустую строку и не придумываем код из ID молча
НормализацияОдин предсказуемый кандидатРегистр, дефис, длина и повторяющиеся разделители заданы явно
ПоискНет элемента с тем же CODEИщем внутри конкретного IBLOCK_ID, а не по всему сайту
СохранениеМетод Add вернул IDПри ошибке сохраняем LAST_ERROR и исходное имя
Проверка ссылкиКаталог находит именно эту записьСверяем URL-шаблон и фильтр детального компонента
\n

Воспроизводимый пример

\n

Ниже функция для последовательного добавления из админки или небольшого импорта. Число 50 здесь не ограничение Bitrix, а мой предел для понятной ошибки: если за пятьдесят попыток не найден свободный вариант, лучше остановиться и посмотреть на входные данные. В реальном проекте ID инфоблока и правило суффикса стоит вынести в конфигурацию.

\n
<?php\n\nfunction getFreeElementCode($iblockId, $name)\n{\n    $base = CUtil::translit(trim($name), "ru", array(\n        "max_len" => 90,\n        "change_case" => "L",\n        "replace_space" => "-",\n        "replace_other" => "-",\n        "delete_repeat_replace" => true,\n    ));\n\n    $base = trim($base, "-");\n    if ($base === "") {\n        throw new InvalidArgumentException("Не удалось получить CODE из NAME");\n    }\n\n    for ($number = 1; $number <= 50; $number++) {\n        $candidate = $number === 1 ? $base : $base . "-" . $number;\n        $result = CIBlockElement::GetList(\n            array(),\n            array("IBLOCK_ID" => (int)$iblockId, "=CODE" => $candidate),\n            false,\n            array("nTopCount" => 1),\n            array("ID")\n        );\n\n        if (!$result->Fetch()) {\n            return $candidate;\n        }\n    }\n\n    throw new RuntimeException("Не найден свободный CODE за 50 попыток");\n}
\n

Знак = в фильтре делает намерение явным: мы ищем конкретный код, а не похожую строку. В выборку достаточно взять ID; имя, картинка и свойства для решения о занятости не нужны. Это маленькая деталь, но она не даёт диагностическому запросу превращаться в выборку всего каталога.

\n

Сохраняем код вместе с элементом

\n

После проверки не нужно делать отдельный Update ради CODE. Документация CIBlockElement::Add допускает поле CODE в массиве полей. Добавляю его в тот же вызов и обязательно разбираю ошибку. Возвращённый ID доказывает запись, но ещё не доказывает, что путь компонента совпадает с проектным URL.

\n
<?php\n\n$element = new CIBlockElement();\n$id = $element->Add(array(\n    "IBLOCK_ID" => 12,\n    "NAME" => $name,\n    "CODE" => getFreeElementCode(12, $name),\n    "ACTIVE" => "N",\n));\n\nif ($id === false) {\n    throw new RuntimeException($element->LAST_ERROR);\n}\n\n// Публикуем только после проверки обязательных данных и ссылки.
\n

Последовательность проверки

\n
  1. Взять два названия, которые различаются только знаками и пробелами, и получить для них кандидаты.
  2. Создать первый элемент на тестовом инфоблоке с исходным кандидатом.
  3. Запустить функцию для второго имени и убедиться, что она вернула суффикс, а не прежний код.
  4. Прочитать оба элемента через CIBlockElement::GetList с тем же IBLOCK_ID.
  5. Открыть детальные страницы и сверить ID в шаблоне или временном логе. Так мы проверяем не только данные, но и используемый компонентом маршрут.
\n

Граница этого решения

\n

Проверка «сначала GetList, потом Add» не является атомарной. Два параллельных воркера могут одновременно увидеть свободный код и попытаться сохранить одинаковое значение. Для ручного ввода и последовательного импорта этого обычно достаточно. Для параллельной синхронизации нужен отдельный проектный механизм: очередь, блокировка или код, связанный со стабильным внешним идентификатором. Какой именно — зависит от версии Bitrix, базы и требований к существующим URL.

\n

Не стоит лечить эту задачу случайным числом в каждом коде. Такой адрес перестаёт быть повторяемым при повторном импорте, а диагностика становится сложнее. Если данные поставщика имеют стабильный артикул, полезно заранее решить, будет ли он участвовать в CODE или останется отдельным свойством. Главное — зафиксировать правило до публикации первой тысячи карточек.

\n

Итог

\n

Символьный код начинается с CUtil::translit, но не заканчивается на нём. Сначала делаем читаемого кандидата, затем проверяем его в нужном инфоблоке, сохраняем результат вместе с элементом и отдельно открываем ссылку. Такой порядок не решает гонку параллельного импорта, зато честно показывает её границу и избавляет от тихих совпадений в обычной работе.

\n

Проверяемые источники

\n
  • Bitrix: CUtil::translit — параметры нормализации строки: регистр, замена пробелов и повторяющихся разделителей
  • Bitrix: CIBlockElement::GetList — выборка элементов по фильтрам IBLOCK_ID, CODE, ACTIVE и с заданным порядком
  • Bitrix: CIBlockElement::Add — создание элемента, поле CODE, возвращаемый ID и LAST_ERROR при ошибке
", + "readingMinutes": 10 }, { "slug": "editorial-2018-03-field-safe-uploads", @@ -4950,45 +4950,48 @@ }, { "slug": "editorial-2018-02-field-php-diagnostics", - "title": "PHP. Диагностика ошибок интеграции: разбор типичной ошибки", + "title": "PHP. Как отличить битый JSON от корректного null в ответе API", "date": "2018-02-25T10:00:00+00:00", "author": "DarkRiDDeR", "categories": [ "PHP", - "Диагностика" + "JSON", + "Интеграции" ], - "cover": "/assets/illustrations/php-ssl-cacert.svg", - "excerpt": "Кейс о том, как интеграция падает только у одного клиента и воспроизвести ситуацию сразу не получается. В конце — последовательность проверки и критерий готовности.", - "contentHtml": "

На реальном проекте эта история обычно начинается спокойно, а затем всплывает один неприятный крайний случай. Разберём «диагностика ошибок интеграции». Типичная ситуация выглядит так: интеграция падает только у одного клиента и воспроизвести ситуацию сразу не получается. В такой момент легко срочно поправить видимый симптом, но полезнее пройти короткое расследование и оставить после него защиту для следующего раза.

\n

Последовательность разбора

\n
  1. Собрать симптомы до изменения конфигурации или кода.
  2. Проверить гипотезу самым маленьким безопасным экспериментом.
  3. Исправить причину, а не только видимый эффект.
  4. Добавить защиту или наблюдение, чтобы случай не вернулся незаметно.
\n

Минимальное доказательство

\n

Нам не нужна идеальная модель всей системы. Достаточно такого эксперимента, который отделяет одну гипотезу от другой: повторить запрос, сравнить входы, посмотреть контекст операции или воспроизвести проблему на отдельной записи. Увидеть исходную ошибку, а не только пустой ответ или false.

\n
$context = [\n    'request_id' => $requestId,\n    'operation' => 'external_api_call',\n    'status' => $response->getStatusCode(),\n];\nerror_log(json_encode($context));
\n

Что меняется после исправления

\n

Исправление считается законченным, когда новый путь проверяется автоматически или наблюдается по явному сигналу. Иначе «диагностика ошибок интеграции» вернётся в следующем релизе под другим именем. Ошибка должна иметь контекст: входные данные, идентификатор операции, место возникновения и безопасный журнал.

\n

Материалы для проверки

\n

Если держать этот порядок, решение остаётся понятным и через несколько месяцев.

", - "readingMinutes": 4 + "cover": "/assets/editorial/2018/json-payload-diagnostic.svg", + "excerpt": "Проверка if (!$data) смешивает пустой массив, false, null и ошибку декодирования. Собираем короткий разбор JSON для PHP 7.1 с проверкой json_last_error и контракта ответа.", + "contentHtml": "

В обработчике ответа часто встречается одна строка: if (!$data) { throw new Exception("bad response"); }. После неё невозможно понять, что случилось: партнёр вернул пустой список, честное null, число 0 или HTML вместо JSON. Ниже я оставляю пример в рамках PHP 7.1: в этой версии ещё нет JSON_THROW_ON_ERROR, поэтому после json_decode() нужно явно проверить состояние декодера.

\n

Главный вопрос здесь узкий: как отделить ошибку разбора JSON от корректного JSON, который не соответствует нашему договору? Ответ состоит из двух проверок подряд. Сначала сразу читаем json_last_error(). Только если там JSON_ERROR_NONE, проверяем тип и обязательные поля ответа.

\n

Почему null не доказывает ошибку

\n

По RFC 8259 JSON-текстом может быть не только объект или массив: допустимы также строка, число, false, true и null. PHP отражает это напрямую: json_decode("null") возвращает null, но null возвращается и когда строку нельзя декодировать. Одна проверка на значение не различает эти случаи.

\n

То же происходит с пустыми коллекциями. После json_decode("[]", true) получится пустой массив, который в PHP является ложным в условии. Это может быть правильный ответ поиска: товаров нет. Но тот же if (!$data) назовёт его «битым JSON». Сначала нужно проверить синтаксис, затем форму данных, и только потом решать, допустим ли пустой результат для данной операции.

\n
\"Схема
Ошибка декодирования и нарушение контракта — разные события. У них разные владельцы и разные действия.
\n

Короткая таблица, которую стоит держать рядом с кодом

\n
Сырой ответРезультат json_decode(..., true)json_last_errorЧто это значит для клиента
{"order_id":"A-17"}ассоциативный массивJSON_ERROR_NONEПроверить поле order_id и принять ответ
[]пустой массивJSON_ERROR_NONEКорректный JSON; допустимость зависит от операции
nullnullJSON_ERROR_NONEКорректный JSON, но не тот тип, который ждёт данный endpoint
false или 0false или 0JSON_ERROR_NONEКорректный JSON; проверка на «ложь» здесь ошибочна
<html>503</html>обычно nullJSON_ERROR_SYNTAXНеверный формат ответа; сохранить безопасный диагностический контекст
\n

Пример для PHP 7.1

\n

Функция ниже принимает уже полученное тело только после проверки HTTP-статуса. Она не пытается угадать, где возникли данные: транспорт и HTTP должны быть разобраны раньше. Здесь контракт намеренно маленький: мы ждём объект с непустым строковым order_id. В другом API это может быть список, поле accepted или код задачи — меняется проверка контракта, но не порядок диагностики.

\n
<?php\n\nfunction logPayloadProblem(array $record)\n{\n    error_log(json_encode($record, JSON_UNESCAPED_UNICODE));\n}\n\nfunction rejectPayloadContract($reason, $requestId, $body)\n{\n    logPayloadProblem(array(\n        'kind' => 'contract_error',\n        'reason' => $reason,\n        'request_id' => $requestId,\n        'body_bytes' => strlen($body),\n        'body_sha256' => hash('sha256', $body),\n    ));\n\n    throw new UnexpectedValueException($reason);\n}\n\nfunction decodeCreatedOrder($body, $requestId)\n{\n    if ($body === '') {\n        rejectPayloadContract('Partner returned an empty body', $requestId, $body);\n    }\n\n    $data = json_decode($body, true);\n    $jsonError = json_last_error();\n\n    if ($jsonError !== JSON_ERROR_NONE) {\n        logPayloadProblem(array(\n            'kind' => 'json_decode_error',\n            'request_id' => $requestId,\n            'json_error' => $jsonError,\n            'body_bytes' => strlen($body),\n            'body_sha256' => hash('sha256', $body),\n        ));\n\n        throw new UnexpectedValueException('Partner response is not valid JSON');\n    }\n\n    if (!is_array($data)) {\n        rejectPayloadContract(\n            'Partner returned valid JSON, but not an object',\n            $requestId,\n            $body\n        );\n    }\n\n    if (\n        !array_key_exists('order_id', $data)\n        || !is_string($data['order_id'])\n        || $data['order_id'] === ''\n    ) {\n        rejectPayloadContract(\n            'Partner JSON has no non-empty order_id',\n            $requestId,\n            $body\n        );\n    }\n\n    return $data;\n}
\n

Значение json_last_error() читается сразу после json_decode(). Это состояние относится к последней операции JSON, поэтому его легко затереть следующим json_encode() или повторным разбором. В примере в журнал попадают код ошибки, размер тела и хеш. Хеш позволяет сравнить два ответа, не печатая сам ответ в общий журнал. Если отладка требует фрагмент тела, её лучше проводить в ограниченной тестовой среде с маскированием данных.

\n

Не путать формат с договором

\n

Предположим, партнёр ответил []. С точки зрения JSON всё в порядке. Для запроса «найди заказы за час» это может быть нормальный нулевой результат. Для запроса «создай заказ» пустой массив не годится, потому что договор ожидает идентификатор. Это уже не ошибка декодера и не повод говорить, что «API вернул битый JSON». Это нарушение контракта полезной нагрузки.

\n

Такая формулировка помогает и при разговоре с партнёром. Вместо расплывчатого «не распарсили ответ» можно передать факт: HTTP-статус был 200, JSON синтаксически корректен, но поле order_id отсутствует или имеет другой тип. Это сообщение можно проверить на их стороне и закрепить в документации API.

\n

Проверка на четырёх маленьких ответах

\n

Тест не обязан ходить в сеть. Достаточно передать функции строки и сравнить исключение или результат. Важно держать рядом успешный пустой сценарий только для той операции, где пустота допустима: иначе тест сам начнёт размывать договор.

\n
  1. Передать {"order_id":"A-17"} и проверить, что функция вернула массив с идентификатором.
  2. Передать <html>maintenance</html>; ожидается ветка json_decode_error с кодом JSON_ERROR_SYNTAX.
  3. Передать null; json_last_error() должен показать успех разбора, а функция должна отклонить неподходящий тип.
  4. Передать []; разбор успешен, но контракт создания заказа должен отклонить отсутствие order_id.
  5. Отдельно проверить поиск или список, где [] является валидным результатом, чтобы не переносить правила одной операции на другую.
\n

Версия PHP и ограничения

\n

В PHP 7.3 появился флаг JSON_THROW_ON_ERROR. В этом примере я намеренно остаюсь на PHP 7.1, поэтому проверка json_last_error() — нормальный механизм для выбранной версии, а не обходной путь. Если проект уже обновлён, исключения могут сделать код компактнее, но проверка формы ответа всё равно остаётся.

\n

Декодер ожидает строку в UTF-8. Ошибка JSON_ERROR_UTF8 говорит о проблеме кодировки, но не объясняет, на каком именно участке она появилась. Для такого случая нужны метрики и безопасный способ сравнить исходные ответы, а не принудительное перекодирование всей строки без понимания источника. RFC 8259 рекомендует уникальные имена в объекте; при повторе разные реализации могут вести себя по-разному. Если поле критично, договор API должен фиксировать его единственность.

\n

Что оставить после исправления

\n

После этой доработки в клиенте остаются два разных события: json_decode_error для невалидного формата и contract_error для валидного, но неожиданного объекта. У них разные причины, разная срочность и разные адресаты. А условие if (!$data) исчезает: оно не способно сказать, что именно произошло.

\n

Проверяемые источники

\n
  • PHP manual: json_decode — что возвращает декодер, требование UTF-8 и изменение PHP 7.3
  • PHP manual: json_last_error — коды ошибок последней операции JSON
  • RFC 8259: JSON — JSON допускает не только объект и массив, но и null, false, true, число и строку
", + "readingMinutes": 9 }, { "slug": "editorial-2018-02-mechanism-php-diagnostics", - "title": "PHP. Почему важна тема: Диагностика ошибок интеграции", + "title": "PHP и cURL. Почему curl_exec() не означает успех интеграции", "date": "2018-02-15T10:00:00+00:00", "author": "DarkRiDDeR", "categories": [ "PHP", - "Диагностика" + "cURL", + "Интеграции" ], - "cover": "/assets/illustrations/php-ssl-cacert.svg", - "excerpt": "Разбираем, почему ошибка должна иметь контекст: входные данные, идентификатор операции, место возникновения и безопасный журнал — и какие ошибки возникают, если этот механизм не учитывать.", - "contentHtml": "

Сначала хотелось просто применить готовый рецепт, но без понимания механизма он быстро превращается в набор случайных действий. Поэтому давайте разберём «диагностика ошибок интеграции». Ошибка должна иметь контекст: входные данные, идентификатор операции, место возникновения и безопасный журнал. Когда этот слой остаётся невидимым, команда начинает лечить следствие: добавляет таймаут, глобальную переменную, второй кеш или ещё одну повторную попытку.

\n

Модель происходящего

\n

Для начала полезно назвать владельца состояния, момент изменения и границу, за которую действие не должно протекать незаметно. Тогда можно отличить нормальную задержку от отказа, локальную оптимизацию от нарушения контракта и временный обход от постоянного решения.

\n
set_error_handler(function ($severity, $message, $file, $line) {\n    error_log(json_encode(['severity' => $severity, 'message' => $message, 'file' => $file, 'line' => $line]));\n    return false;\n});
\n

Как проверить модель на практике

\n
  1. Назвать границу, на которой действует механизм.
  2. Зафиксировать, что считается успехом и отказом.
  3. Проверить, какие данные или ресурсы остаются после ошибки.
  4. Добавить измерение, которое подтвердит вывод в следующем проекте.
\n

Ограничения

\n

У этой модели нет магической силы: интеграция падает только у одного клиента и воспроизвести ситуацию сразу не получается. Поэтому в рабочем проекте нужно добавлять наблюдение, разумные лимиты и понятный путь отката. Чем дороже ошибка, тем важнее заранее проговорить этот случай.

\n

Материалы для проверки

\n

Если держать этот порядок, решение остаётся понятным и через несколько месяцев.

", - "readingMinutes": 3 + "cover": "/assets/editorial/2018/curl-outcome-classifier.svg", + "excerpt": "curl_exec() может вернуть тело ответа, хотя партнёр ответил 404 или 500. Разбираю три уровня результата: транспорт, HTTP и контракт полезной нагрузки.", + "contentHtml": "

После ночной выгрузки в логе стоит «запрос выполнен», потому что curl_exec() вернул строку. Утром выясняется, что строкой была HTML-страница с 403, а заказы не дошли. Ошибка в проверке не синтаксическая: код спросил cURL только о доставке ответа, а бизнес-код сделал вывод о результате всей операции. Разберём один вопрос: какой минимальный набор проверок отличает сетевой сбой, HTTP-отказ и рабочий ответ партнёра?

\n

У одного вызова три разных результата

\n

При включённом CURLOPT_RETURNTRANSFER функция curl_exec() возвращает тело ответа при успехе cURL и false при его ошибке. Проверять результат надо строгим сравнением: непустое тело может быть строкой "0", которая в обычном условии ведёт себя как ложь. Главное здесь другое: статус 404 или 500 сам по себе не считается ошибкой cURL. Документация прямо предлагает читать HTTP-статус через curl_getinfo().

\n

Отсюда порядок проверки. Сначала узнаём, состоялась ли передача: $body === false, curl_errno() и curl_error(). Затем читаем http_code, тип содержимого и время из curl_getinfo(). Только после этого разбираем тело как JSON или иной формат, который обещан договором с партнёром. Если смешать уровни, журнал начинает сообщать «ошибка API» и для DNS, и для 401, и для сломанного JSON.

\n
\"Диаграмма
Положительный результат cURL означает, что библиотека получила ответ. Он ещё не означает, что HTTP-запрос и бизнес-операция завершились успешно.
\n

Что сохранять для каждого уровня

\n
НаблюдениеКласс сбояЧто записать в журналСледующее действие
$body === falseТранспорт или TLScurl_errno, curl_error, URL без секрета, времяПроверить DNS, сертификат, таймаут и доступность хоста
Есть тело, http_code 401 или 403Авторизация или праваHTTP-код, операция, внешний ID, request IDПроверить учётные данные и область доступа; не печатать токен
Есть тело, http_code 404Адрес или версия APIHTTP-код и маршрут без query-параметровСверить путь, метод и версию endpoint
Есть тело, http_code 500Ошибка удалённой стороныHTTP-код, request ID, первые безопасные признаки ответаПередать партнёру ID запроса и время, не повторять запись вслепую
2xx и ожидаемое телоТранспорт и HTTP прошлиКод, размер и время ответаПроверить обязательные поля тела перед изменением локальных данных
\n

Клиент, который не прячет уровень ошибки

\n

В примере ниже нет общего «интеграционного клиента». Нужна маленькая функция, которую легко вызвать в изолированном скрипте и легко покрыть разными ответами. Время соединения и общий таймаут здесь проектные: их надо выбирать под договорённость с конкретным сервисом, а не переносить числа из чужого кода.

\n
<?php\n\nfunction requestPartner($url, $requestId)\n{\n    $handle = curl_init($url);\n\n    curl_setopt_array($handle, array(\n        CURLOPT_RETURNTRANSFER => true,\n        CURLOPT_CONNECTTIMEOUT => 3,\n        CURLOPT_TIMEOUT => 10,\n        CURLOPT_HTTPHEADER => array(\n            'Accept: application/json',\n            'X-Request-Id: ' . $requestId,\n        ),\n    ));\n\n    $body = curl_exec($handle);\n    $curlErrno = curl_errno($handle);\n    $curlError = curl_error($handle);\n    $info = curl_getinfo($handle);\n    curl_close($handle);\n\n    if ($body === false) {\n        throw new RuntimeException(json_encode(array(\n            'kind' => 'transport_error',\n            'request_id' => $requestId,\n            'curl_errno' => $curlErrno,\n            'curl_error' => $curlError,\n            'total_time' => $info['total_time'],\n        )));\n    }\n\n    $status = (int) $info['http_code'];\n    if ($status < 200 || $status >= 300) {\n        throw new RuntimeException(json_encode(array(\n            'kind' => 'http_error',\n            'request_id' => $requestId,\n            'http_code' => $status,\n            'content_type' => $info['content_type'],\n            'body_bytes' => strlen($body),\n            'total_time' => $info['total_time'],\n        )));\n    }\n\n    return array(\n        'body' => $body,\n        'content_type' => $info['content_type'],\n        'http_code' => $status,\n        'total_time' => $info['total_time'],\n    );\n}
\n

Функция намеренно не пишет URL целиком. Query-параметры часто содержат ключи, подписи или персональные идентификаторы. Если адрес нужен для расследования, лучше сохранить имя интеграции и заранее нормализованный путь. Тело ответа тоже не стоит бездумно добавлять к исключению: для первичного поиска достаточно размера, типа содержимого и ID операции; безопасный фрагмент можно сохранить отдельно в тестовой среде.

\n

Почему 2xx — ещё не результат операции

\n

HTTP-код описывает ответ сервера на протокольном уровне. Он не может за нас подтвердить, что партнёр принял заказ в нужном виде. Один API возвращает {"id":"A-17"}, другой — {"accepted":true}, третий ставит задачу в очередь. Поэтому после 2xx должна быть проверка конкретного поля, которое выбрано в контракте. Разбор JSON и формы ответа — отдельная граница; её нельзя заменять условием if ($body).

\n

Это же объясняет, почему автоматический повтор записи нельзя включать как реакцию на любой сбой. При таймауте неизвестно, дошёл ли запрос до партнёра. Если операция создаёт заказ, второй POST может создать дубликат. Повтор становится безопасным только когда протокол даёт ключ идемпотентности, внешний ID или отдельный способ узнать результат первой попытки. Пока такого условия нет, в журнале должна появиться операция для разбора, а не второй запрос в фоне.

\n

Воспроизводимая матрица проверки

\n

Для проверки не нужен настоящий партнёр. Достаточно небольшого тестового endpoint, который по параметру возвращает 200 с JSON, 401, 500 и закрывает соединение. Важно сравнивать не только текст исключения, но и поля записи: у каждого сценария должен быть свой kind. Тогда мониторинг может отдельно считать транспортные сбои и ответы 5xx.

\n
  1. Включить CURLOPT_RETURNTRANSFER и заменить все проверки if (!$body) на строгое $body === false.
  2. Сразу после curl_exec() собрать curl_errno, curl_error и curl_getinfo, пока handle не закрыт.
  3. Прогнать endpoint с недоступным адресом и проверить ветку transport_error с ненулевым кодом cURL.
  4. Прогнать 401, 404 и 500; у них должна сработать ветка http_error, а не транспортная ошибка.
  5. Прогнать 200 с корректным, но неожиданным телом и убедиться, что следующий слой контракта его не принимает автоматически.
\n

Границы примера

\n

Пример не задаёт универсальные таймауты и не обещает повтор запросов. Он не проверяет сертификаты вручную и не отключает TLS-проверку: если есть ошибка сертификата, её следует увидеть как транспортную причину и исправить настройку окружения. Он также не заменяет лимиты на размер ответа и контроль метода HTTP — эти правила зависят от конкретного API.

\n

Но даже такая небольшая развилка меняет качество диагностики. Вместо одного сообщения «интеграция не работает» появляются три проверяемых факта: передача не состоялась, удалённый сервер ответил не тем статусом или статус нормальный, но тело не прошло контракт. Дальше можно обсуждать решение с человеком, который отвечает именно за этот слой.

\n

Проверяемые источники

\n", + "readingMinutes": 10 }, { "slug": "editorial-2018-02-practice-php-diagnostics", - "title": "PHP. Диагностика ошибок интеграции: рабочая схема", + "title": "PHP. Как записать причину 500-й ошибки в интеграции", "date": "2018-02-07T10:00:00+00:00", "author": "DarkRiDDeR", "categories": [ "PHP", - "Диагностика" + "Отладка", + "Интеграции" ], - "cover": "/assets/illustrations/php-ssl-cacert.svg", - "excerpt": "Практическая заметка о том, как увидеть исходную ошибку, а не только пустой ответ или false. С минимальной схемой, проверкой результата и ограничениями.", - "contentHtml": "

Периодически в проекте встречается задача, которая с виду кажется мелкой, а потом съедает полдня. В этот раз разбираюсь с темой «диагностика ошибок интеграции». Цель заметки — увидеть исходную ошибку, а не только пустой ответ или false. Не будем начинать с большой переделки: сначала соберём минимальный сценарий, который можно показать коллеге и повторить на чистой среде.

\n

Минимальная рабочая схема

\n

Первое правило здесь простое: отделяем входные данные от побочного эффекта. До того как менять состояние системы, проверяем условия, назначаем понятный идентификатор операции и оставляем достаточно контекста для диагностики. Ошибка должна иметь контекст: входные данные, идентификатор операции, место возникновения и безопасный журнал.

\n
if (($_FILES['image']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {\n    throw new RuntimeException('Upload failed');\n}\n$tmp = $_FILES['image']['tmp_name'];\n$target = $storage . '/' . bin2hex(random_bytes(12)) . '.jpg';\nif (!move_uploaded_file($tmp, $target)) {\n    throw new RuntimeException('Cannot move upload');\n}
\n

Что проверяем после запуска

\n
  1. Сформулировать вход и ожидаемый результат: увидеть исходную ошибку, а не только пустой ответ или false.
  2. Выполнить минимальный сценарий отдельно от остальной системы.
  3. Проверить отрицательный путь: интеграция падает только у одного клиента и воспроизвести ситуацию сразу не получается.
  4. Сохранить наблюдаемый результат в тесте, логе или коротком runbook.
\n

Где чаще всего ошибаются

\n

Опасность темы «диагностика ошибок интеграции» не в сложном синтаксисе, а в неявных предположениях. Интеграция падает только у одного клиента и воспроизвести ситуацию сразу не получается. Если решение зависит от версии среды, внешней системы или прав пользователя, это лучше проверить отдельным шагом и записать рядом с кодом.

\n

Материалы для проверки

\n

Если держать этот порядок, решение остаётся понятным и через несколько месяцев.

", - "readingMinutes": 3 + "cover": "/assets/editorial/2018/php-fatal-context-flow.svg", + "excerpt": "Разбираю, как собрать один полезный диагностический факт при фатальной ошибке PHP: где работает set_error_handler, зачем нужен shutdown-обработчик и какие данные нельзя писать в лог.", + "contentHtml": "

Интеграционный endpoint вернул 500, а в журнале осталась только дата и адрес скрипта. На следующий день партнёр повторяет запрос, но уже с другими данными, и причина исчезает. В такой ситуации не помогает ещё один try/catch вокруг вызова API: часть ошибок PHP до него не дойдёт. Вопрос этой заметки простой: как оставить один диагностический факт с операцией и местом падения, не превращая журнал в копию чужого запроса?

\n

Почему одного set_error_handler недостаточно

\n

Первое, что обычно хочется сделать, — повесить set_error_handler и считать задачу закрытой. У функции есть граница: пользовательский обработчик не получает E_ERROR, E_PARSE, E_CORE_ERROR и E_COMPILE_ERROR. Он также не может увидеть ошибку, случившуюся до регистрации обработчика. Это не дефект функции, а условие, от которого надо строить диагностику.

\n

Поэтому я разделяю три случая. Обычное предупреждение попадает в обработчик ошибок. Непойманное исключение или Error в PHP 7 попадает в обработчик исключений. Для части фатальных ошибок остаётся функция завершения: PHP вызывает её после окончания скрипта или после exit(), а error_get_last() даёт тип, сообщение, файл и строку последней ошибки. Функция завершения не заменяет нормальную обработку исключений, но закрывает именно этот зазор.

\n
\"Схема:
Один request ID проходит через все три ветки. В журнале видно не только текст PHP, но и операцию, на которой он возник.
\n

Сначала определить, что именно нужно найти потом

\n

Лог полезен, если по одной записи можно ответить на четыре вопроса: какая операция шла, какой внешний идентификатор обрабатывался, где остановился код и какой класс ошибки случился. Записывать целиком $_POST, заголовок авторизации или ответ партнёра для этого не нужно. В них часто лежат пароли, персональные данные и токены; при расследовании такой журнал создаёт вторую проблему.

\n

Для импорта заказа я оставляю короткий контекст: случайный ID операции, имя интеграции, внешний ID заказа и этап. Этап меняется перед опасным участком: request_prepared, partner_called, response_saved. Если процесс оборвался, последняя метка намного полезнее догадки по номеру строки.

\n
Поле журналаПримерЗачем оно нужно
request_idsync-20180207-4f2aСвязать запись PHP с логом веб-сервера и сообщением партнёра
operationorder_exportНе смешать импорт каталога, webhook и ручной запуск
external_idORD-9182Повторить один сценарий без поиска по всему набору данных
stagepartner_calledПонять, успел ли код дойти до внешнего вызова
error_typeE_ERROR или ThrowableОтделить ошибку PHP от ответа HTTP
file, lineпуть и строкаОткрыть точку падения в той версии кода, которая работала в момент сбоя
\n

Минимальная обвязка для PHP 7

\n

Ниже пример для одного HTTP-запроса. Он не пытается перехватить всё подряд и не меняет поведение штатного обработчика PHP: после записи предупреждения возвращается false. Это удобно на первом внедрении: существующие настройки error_reporting и журнал сервера остаются на месте, а рядом появляется структурированная запись для интеграции.

\n
<?php\n\nfunction writeIntegrationLog(array $record)\n{\n    error_log(json_encode($record, JSON_UNESCAPED_UNICODE));\n}\n\nfunction installIntegrationDiagnostics($requestId, $operation, $externalId)\n{\n    $context = array(\n        'request_id' => $requestId,\n        'operation' => $operation,\n        'external_id' => $externalId,\n        'stage' => 'started',\n    );\n\n    $setStage = function ($stage) use (&$context) {\n        $context['stage'] = $stage;\n    };\n\n    set_error_handler(function ($severity, $message, $file, $line) use (&$context) {\n        if (!(error_reporting() & $severity)) {\n            return false;\n        }\n\n        writeIntegrationLog($context + array(\n            'kind' => 'php_error',\n            'error_type' => $severity,\n            'message' => $message,\n            'file' => $file,\n            'line' => $line,\n        ));\n\n        return false;\n    });\n\n    set_exception_handler(function (Throwable $error) use (&$context) {\n        writeIntegrationLog($context + array(\n            'kind' => 'uncaught_throwable',\n            'class' => get_class($error),\n            'message' => $error->getMessage(),\n            'file' => $error->getFile(),\n            'line' => $error->getLine(),\n        ));\n    });\n\n    register_shutdown_function(function () use (&$context) {\n        $last = error_get_last();\n        $fatalTypes = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR);\n\n        if ($last === null || !in_array($last['type'], $fatalTypes, true)) {\n            return;\n        }\n\n        writeIntegrationLog($context + array(\n            'kind' => 'fatal_error',\n            'error_type' => $last['type'],\n            'message' => $last['message'],\n            'file' => $last['file'],\n            'line' => $last['line'],\n        ));\n    });\n\n    return $setStage;\n}\n\n$setStage = installIntegrationDiagnostics(\n    'sync-20180207-4f2a',\n    'order_export',\n    'ORD-9182'\n);\n\n$setStage('request_prepared');\n// Здесь вызывается клиент партнёра.\n$setStage('partner_called');
\n

В настоящем коде генерация request_id и запись журнала обычно живут в приложении, а не в каждой интеграции. Здесь они оставлены рядом, чтобы видно было главное: контекст создаётся до внешнего вызова, а не в блоке обработки ошибки. Нельзя восстановить по фатальной ошибке то, что код не успел записать.

\n

Как проверить схему до аварии

\n

Проверять такую обвязку лучше не на боевом заказе. Для предупреждения достаточно отдельного скрипта с trigger_error("diagnostic test", E_USER_WARNING). Для исключения — выбросить RuntimeException после установки этапа. Фатальный путь нужно запускать только в изолированной среде: ошибка, которую нельзя перехватить через set_error_handler, должна оставить запись из shutdown-функции, а сам тест не должен менять состояние сторонней системы.

\n
  1. Добавить обвязку в точку входа до вызова клиента интеграции и задать request_id, операцию и внешний ID.
  2. Запустить локальный сценарий с предупреждением и убедиться, что в журнале есть все поля таблицы, а штатное сообщение PHP не исчезло.
  3. Запустить сценарий с непойманным исключением в отдельном endpoint и проверить запись с классом исключения и последним этапом.
  4. В тестовой среде проверить фатальный случай после регистрации обработчиков и убедиться, что shutdown-запись не дублирует обычные предупреждения.
  5. Открыть журнал с позиции человека, который не видел код: по одной строке должно быть понятно, какой внешний объект повторять и где смотреть дальше.
\n

Где эта схема заканчивается

\n

Она не ловит синтаксическую ошибку в файле, который не дал приложению стартовать: обработчики ещё не зарегистрированы. Она не гарантирует запись при принудительном завершении процесса. Она не заменяет мониторинг 500-х на уровне веб-сервера. И она не даёт права сохранять секреты в журнал. Для таких случаев остаются деплой-проверки, журналы окружения и правила маскирования данных.

\n

Ещё одна граница — дубли. Ошибка внутри set_error_handler и ошибка в shutdown-функции не должны сами вызвать бесконечный поток записей. Поэтому запись должна быть короткой, а логгер — максимально простым. Если для доставки лога нужен сетевой запрос, я бы не ставил его в shutdown-путь: при падении сети потеряем и исходную ошибку, и время на разбор.

\n

Порядок, который остаётся в проекте

\n

Сначала ставим контекст, затем меняем этапы перед побочными эффектами, потом отдельно видим предупреждение, исключение и фатальный случай. После этого ошибка 500 перестаёт быть сообщением «что-то не так». В ней есть операция, внешний объект, последняя пройденная граница и место в коде. Этого достаточно, чтобы воспроизвести проблему до следующего запроса партнёра.

\n

Проверяемые источники

\n", + "readingMinutes": 10 }, { "slug": "editorial-2018-01-field-bitrix-elements", @@ -5002,7 +5005,7 @@ ], "cover": "/assets/editorial/2018/bitrix-visibility-diagnostic.svg", "excerpt": "Полевой разбор частой ошибки Bitrix: Add вернул ID, админка показывает элемент, но пользователь не видит его в каталоге. Ищем причину по слоям, а не очищаем кеш наугад.", - "contentHtml": "

Знакомая картина: скрипт вернул ID, в админке новый товар есть, а на сайте его нет. Первый импульс — «почистить кеш». Иногда это действительно помогает, но чаще кеш просто оказывается первым подозреваемым, потому что его легко назвать. Давайте сначала отделим факт записи от публичной видимости и пройдём путь теми же условиями, которыми живёт каталог.

\n

Постановка проблемы

\n

Админка и публичный компонент редко показывают одинаковую выборку. Админка может отобразить неактивный элемент, а каталог фильтрует по ACTIVE, датам активности, разделу, правам, цене, наличию и проектным свойствам. Поэтому вопрос «почему элемент не виден?» нельзя решать одной командой. Нужен короткий список слоёв и доказательство на каждом.

\n
\"Дерево
Начинаем не с кеша, а с самого раннего условия, которое может исключить элемент из публичной выборки.
\n

Проверяем по слоям

\n
СлойЧто проверяемКак получить доказательство
ЗаписьAdd вернул ID, LAST_ERROR пустЛог результата и внешний ID операции
ИнфоблокACTIVE, даты, символьный код, разделКонтрольная выборка с теми же базовыми фильтрами
СвойстваОбязательная связь, SKU, картинка, проектные флагиЧтение конкретных свойств для созданного ID
КаталогЦена, остаток, доступность — если компонент их требуетПроверка конфигурации каталога и товарных параметров
Публичный путьФильтр компонента, права, кеш и индексПовтор сценария от имени нужного пользователя
\n

Контрольный запрос вместо догадки

\n

Документация CIBlockElement::GetList описывает фильтры ACTIVE, ACTIVE_DATE и выбор нужных полей. Ниже не универсальный каталоговый запрос, а диагностическая проба. Она отвечает на первый важный вопрос: проходит ли наш элемент хотя бы базовые условия публичной выдачи. Если нет — проблему надо искать в данных, а не в шаблоне.

\n
<?php\n\nconst PRODUCT_IBLOCK_ID = 12;\n\n$result = CIBlockElement::GetList(\n    [],\n    [\n        "IBLOCK_ID" => PRODUCT_IBLOCK_ID,\n        "=ID" => $elementId,\n        "ACTIVE" => "Y",\n        "ACTIVE_DATE" => "Y",\n    ],\n    false,\n    ["nTopCount" => 1],\n    ["ID", "IBLOCK_ID", "NAME", "CODE", "ACTIVE", "DATE_ACTIVE_FROM", "DATE_ACTIVE_TO"]\n);\n\n$row = $result->Fetch();\nif ($row === false) {\n    throw new RuntimeException("Элемент не проходит базовый публичный фильтр");\n}
\n

Где здесь каталог

\n

Элемент инфоблока и товарная часть каталога — соседние, но разные уровни. Если публичный компонент требует цену, остаток или связь торгового предложения с товаром, одного CIBlockElement::Add недостаточно. Документация каталога отдельно описывает товарные параметры; в старом коде можно встретить CCatalogProduct::Add, но текущая документация помечает его устаревшим и рекомендует модель \\Bitrix\\Catalog\\Model\\Product. Для исторического проекта это не повод переписывать всё за вечер, а повод явно зафиксировать используемую версию API и не смешивать создание элемента с догадкой о его товарном состоянии.

\n

Мини-матрица симптомов

\n
СимптомСамая частая причинаБезопасное следующее действие
Нет IDОшибка обязательного поля, свойства или правВывести LAST_ERROR и входной внешний ID
ID есть, базовый GetList пустACTIVE, дата, инфоблок или неверный IDСначала читать поля элемента без публичных фильтров
GetList есть, карточки нетДополнительный фильтр компонента, раздел, права, URLСравнить фильтр и маршрут компонента с контрольной выборкой
Карточка есть, нельзя купитьНе настроены параметры каталога, цена или остатокПроверить товарный слой отдельно от инфоблока
После изменения появляется не сразуКеш или индексПодтвердить корректность данных и только затем адресно обновлять кеш/индекс
\n

Почему не стоит начинать с очистки кеша

\n

Потому что очистка кеша скрывает различие между двумя ситуациями: данные корректны, но слой кеширования устарел; или данные с самого начала не удовлетворяют фильтру. В первом случае нужна адресная стратегия инвалидирования. Во втором — очистка не решит проблему, а только добавит шума. Хорошая диагностика оставляет после себя не только исправленный товар, но и понимание, какое условие не было выполнено.

\n

Чек-лист перед закрытием задачи

\n
  1. Зафиксировать ID созданного элемента и внешний идентификатор операции.
  2. Считать элемент без публичных ограничений и проверить, что ожидаемые поля и свойства сохранены.
  3. Повторить контрольную выборку с ACTIVE и ACTIVE_DATE.
  4. Проверить условия конкретного компонента: раздел, права, проектные фильтры, URL.
  5. Если это товар — отдельно проверить цену, остаток и доступность, не смешивая этот слой с данными инфоблока.
  6. Только после этого проверять кеш и индекс; зафиксировать, какое именно действие обновляет их в данном проекте.
\n

Проверяемые источники

\n\n

Итог

\n

Фраза «элемент есть в админке» говорит только о том, что одна запись сохранилась. Для каталога этого недостаточно. Если идти от ID к базовой выборке, от неё к товарному слою и только затем к кешу, причина обычно находится быстро. И самое приятное: на следующей похожей задаче уже не нужно вспоминать магическую кнопку очистки — есть нормальный порядок проверки.

", + "contentHtml": "

Знакомая картина: скрипт вернул ID, в админке новый товар есть, а на сайте его нет. Первый импульс — «почистить кеш». Иногда это действительно помогает, но чаще кеш просто оказывается первым подозреваемым, потому что его легко назвать. Давайте сначала отделим факт записи от публичной видимости и пройдём путь теми же условиями, которыми живёт каталог.

\n

Постановка проблемы

\n

Админка и публичный компонент редко показывают одинаковую выборку. Админка может отобразить неактивный элемент, а каталог фильтрует по ACTIVE, датам активности, разделу, правам, цене, наличию и проектным свойствам. Поэтому вопрос «почему элемент не виден?» нельзя решать одной командой. Нужен короткий список слоёв и доказательство на каждом.

\n

Полезно сразу сохранить два разных наблюдения: «запись читается по ID без ограничений» и «запись попадает в публичную выборку». Между ними могут стоять несколько независимых условий. Если журнал хранит только успешный ID, а не фильтр и результат контрольного запроса, следующему разработчику останется лишь гадать, какая граница исключила товар.

\n
\"Дерево
Начинаем не с кеша, а с самого раннего условия, которое может исключить элемент из публичной выборки.
\n

Проверяем по слоям

\n
СлойЧто проверяемКак получить доказательство
ЗаписьAdd вернул ID, LAST_ERROR пустЛог результата и внешний ID операции
ИнфоблокACTIVE, даты, символьный код, разделКонтрольная выборка с теми же базовыми фильтрами
СвойстваОбязательная связь, SKU, картинка, проектные флагиЧтение конкретных свойств для созданного ID
КаталогЦена, остаток, доступность — если компонент их требуетПроверка конфигурации каталога и товарных параметров
Публичный путьФильтр компонента, права, кеш и индексПовтор сценария от имени нужного пользователя
\n

Контрольный запрос вместо догадки

\n

Документация CIBlockElement::GetList описывает фильтры ACTIVE, ACTIVE_DATE и выбор нужных полей. Ниже не универсальный каталоговый запрос, а диагностическая проба. Она отвечает на первый важный вопрос: проходит ли наш элемент хотя бы базовые условия публичной выдачи. Если нет — проблему надо искать в данных, а не в шаблоне.

\n
<?php\n\nconst PRODUCT_IBLOCK_ID = 12;\n\n$result = CIBlockElement::GetList(\n    [],\n    [\n        "IBLOCK_ID" => PRODUCT_IBLOCK_ID,\n        "=ID" => $elementId,\n        "ACTIVE" => "Y",\n        "ACTIVE_DATE" => "Y",\n    ],\n    false,\n    ["nTopCount" => 1],\n    ["ID", "IBLOCK_ID", "NAME", "CODE", "ACTIVE", "DATE_ACTIVE_FROM", "DATE_ACTIVE_TO"]\n);\n\n$row = $result->Fetch();\nif ($row === false) {\n    throw new RuntimeException("Элемент не проходит базовый публичный фильтр");\n}
\n

Где здесь каталог

\n

Элемент инфоблока и товарная часть каталога — соседние, но разные уровни. Если публичный компонент требует цену, остаток или связь торгового предложения с товаром, одного CIBlockElement::Add недостаточно. Документация каталога отдельно описывает товарные параметры; в старом коде можно встретить CCatalogProduct::Add, но текущая документация помечает его устаревшим и рекомендует модель \\Bitrix\\Catalog\\Model\\Product. Для исторического проекта это не повод переписывать всё за вечер, а повод явно зафиксировать используемую версию API и не смешивать создание элемента с догадкой о его товарном состоянии.

\n

Мини-матрица симптомов

\n
СимптомСамая частая причинаБезопасное следующее действие
Нет IDОшибка обязательного поля, свойства или правВывести LAST_ERROR и входной внешний ID
ID есть, базовый GetList пустACTIVE, дата, инфоблок или неверный IDСначала читать поля элемента без публичных фильтров
GetList есть, карточки нетДополнительный фильтр компонента, раздел, права, URLСравнить фильтр и маршрут компонента с контрольной выборкой
Карточка есть, нельзя купитьНе настроены параметры каталога, цена или остатокПроверить товарный слой отдельно от инфоблока
После изменения появляется не сразуКеш или индексПодтвердить корректность данных и только затем адресно обновлять кеш/индекс
\n

Почему не стоит начинать с очистки кеша

\n

Потому что очистка кеша скрывает различие между двумя ситуациями: данные корректны, но слой кеширования устарел; или данные с самого начала не удовлетворяют фильтру. В первом случае нужна адресная стратегия инвалидирования. Во втором — очистка не решит проблему, а только добавит шума. Хорошая диагностика оставляет после себя не только исправленный товар, но и понимание, какое условие не было выполнено.

\n

Чек-лист перед закрытием задачи

\n
  1. Зафиксировать ID созданного элемента и внешний идентификатор операции.
  2. Считать элемент без публичных ограничений и проверить, что ожидаемые поля и свойства сохранены.
  3. Повторить контрольную выборку с ACTIVE и ACTIVE_DATE.
  4. Проверить условия конкретного компонента: раздел, права, проектные фильтры, URL.
  5. Если это товар — отдельно проверить цену, остаток и доступность, не смешивая этот слой с данными инфоблока.
  6. Только после этого проверять кеш и индекс; зафиксировать, какое именно действие обновляет их в данном проекте.
\n

Проверяемые источники

\n\n

Итог

\n

Фраза «элемент есть в админке» говорит только о том, что одна запись сохранилась. Для каталога этого недостаточно. Если идти от ID к базовой выборке, от неё к товарному слою и только затем к кешу, причина обычно находится быстро. И самое приятное: на следующей похожей задаче уже не нужно вспоминать магическую кнопку очистки — есть нормальный порядок проверки.

", "readingMinutes": 10 }, { @@ -5017,7 +5020,7 @@ ], "cover": "/assets/editorial/2018/bitrix-add-lifecycle.svg", "excerpt": "Разбираем жизненный цикл добавления элемента: кто проверяет поля, где срабатывают события Bitrix, почему глобальный обработчик не заменяет сервис и как тестировать эту границу.", - "contentHtml": "

Когда Bitrix-проект разрастается, вокруг простого CIBlockElement::Add появляется невидимый код: обработчики событий, правила символьного кода, импортеры, каталог, поиск и шаблоны. Из-за этого одинаковый вызов сегодня работает из формы, а завтра падает из консольного скрипта. Давайте разложим путь записи по шагам и не будем прятать бизнес-правило в месте, где его трудно обнаружить.

\n

Карта жизненного цикла

\n

Документация Bitrix говорит важную вещь: перед добавлением вызывается OnBeforeIBlockElementAdd. Обработчик получает поля по ссылке, поэтому способен их изменить; чтобы отменить запись, он должен установить исключение через $APPLICATION->ThrowException() и вернуть false. После успешной записи срабатывают события после добавления. Это значит, что обработчик — реальная часть контракта метода, а не декоративная «магия в init.php».

\n
\"Последовательность
ID возвращается из слоя инфоблока, но качество результата подтверждается уже в пользовательском сценарии.
\n

Где живёт каждое правило

\n
МестоХорошая ответственностьЧто туда не стоит класть
Сервис созданияПроверка входа, подготовка полей, перевод ошибки в понятный результатГлобальные побочные эффекты для любого инфоблока
OnBeforeIBlockElementAddПоследний общий барьер: запрет пустого CODE, аудит общей политикиВнешние HTTP-вызовы, тяжёлую обработку файлов, правила одного экрана
После записиОтправка события, фоновая реакция, журналирование успешной операцииИзменение результата, от которого зависит успех текущего Add
Публичный компонентФильтрация и отображение данныхИсправление отсутствующих обязательных данных «на лету»
\n

Минимальный предохранитель в событии

\n

Ниже — не замена сервису, а общий барьер для конкретного инфоблока. Он предотвращает запись элемента без символьного кода независимо от того, откуда пришёл вызов: админка, импорт или самописный endpoint. Важно, что код не пытается угадать всё бизнес-правило товара. Он проверяет только инвариант, который действительно должен быть общим.

\n
<?php\n\nconst PRODUCT_IBLOCK_ID = 12;\n\nAddEventHandler(\n    "iblock",\n    "OnBeforeIBlockElementAdd",\n    ["CatalogElementGuard", "beforeAdd"]\n);\n\nfinal class CatalogElementGuard\n{\n    public static function beforeAdd(array &$fields): bool\n    {\n        if ((int)($fields["IBLOCK_ID"] ?? 0) !== PRODUCT_IBLOCK_ID) {\n            return true;\n        }\n\n        if (trim((string)($fields["CODE"] ?? "")) === "") {\n            global $APPLICATION;\n            $APPLICATION->ThrowException("Для товара нужен символьный код");\n            return false;\n        }\n\n        return true;\n    }\n}
\n

Почему событие не должно быть единственным валидатором

\n

Потому что событие не знает намерения конкретной операции. Один экран может создавать черновик без картинки, другой — импортировать поставщика, третий — мигрировать старые записи. Если все проверки спрятать в OnBeforeIBlockElementAdd, получится глобальная функция с десятком условий и неожиданными побочными эффектами. Сервис создания должен объяснять, почему он принимает или отклоняет вход. Событие лишь страхует инвариант, который действует для всех.

\n

Сервис остаётся точкой диагностики

\n
<?php\n\nfunction addCatalogElement(array $fields): int\n{\n    if (!\\Bitrix\\Main\\Loader::includeModule("iblock")) {\n        throw new RuntimeException("Модуль iblock не подключён");\n    }\n\n    $element = new CIBlockElement();\n    $id = $element->Add($fields);\n\n    if ($id === false) {\n        $message = $element->LAST_ERROR ?: "Bitrix не вернул причину ошибки";\n        throw new RuntimeException($message);\n    }\n\n    return (int)$id;\n}
\n

После записи — это уже другой разговор

\n

Обработчик после добавления удобен для журналирования, запуска поиска или отправки внутреннего уведомления. Но он не должен молча решать судьбу уже созданного элемента. Если побочный шаг упал после того, как Add вернул ID, повторный вызов Add из обработчика легко создаст дубль, а внешний сервис получит два одинаковых запроса. Поэтому после записи я бы сохранял ID, внешний ключ и понятный статус операции, а ошибку реакции разбирал как отдельную задачу.

\n

Если синхронизация с внешней системой действительно обязательна для публикации товара, полезно разделить два состояния: «элемент сохранён» и «элемент готов для пользователя». Первый факт подтверждает сервис создания, второй — контрольная проверка после всех зависимых действий. Тогда временный сбой индексации или уведомления не превращается в неясную историю, где никто не понимает, можно ли безопасно повторить импорт.

\n

Как тестировать такую связку

\n

В 2018-м легко ограничиться ручной проверкой в админке, но здесь полезно хотя бы зафиксировать короткую матрицу. Она не требует сложного тестового фреймворка: часть сценариев можно выполнить на тестовом инфоблоке и сохранить как чек-лист релиза. Главное — проверять и прямой сервис, и поведение глобального события.

\n
СценарийОжиданиеГде искать ошибку при сбое
Корректный элементСервис возвращает ID, элемент читаетсяПоля сервиса и конфигурация инфоблока
Пустой CODEЗапись отменена, причина понятна вызывающему кодуОбработчик OnBeforeIBlockElementAdd
Другой инфоблокОхранник не вмешиваетсяСлишком широкое условие в обработчике
Импорт или CLIРезультат тот же, что из формыСкрытая зависимость от HTTP-сессии или интерфейса
\n

Проверяемые источники

\n
  • CIBlockElement::Add — контракт метода, обработчики до и после записи, ID и LAST_ERROR
  • OnBeforeIBlockElementAdd — как обработчик может изменить поля или отменить запись
  • CIBlockElement::SetPropertyValuesEx — точечное сохранение свойств и особенности пустых значений
\n

Итог

\n

События Bitrix полезны, когда их граница ясна. Общий инвариант — в обработчик. Намерение операции, логирование и перевод ошибки — в сервис. Публичная видимость — в отдельную проверку после создания. С такой схемой даже старый проект перестаёт выглядеть набором случайных init.php-заклинаний: у каждого правила появляется место и причина.

", + "contentHtml": "

Проблема появляется, когда Bitrix-проект разрастается вокруг простого CIBlockElement::Add: обработчики событий, правила символьного кода, импортеры, каталог, поиск и шаблоны становятся невидимой частью вызова. Из-за этого одинаковый код сегодня работает из формы, а завтра падает из консольного скрипта. Давайте разложим путь записи по шагам и не будем прятать бизнес-правило в месте, где его трудно обнаружить.

\n

Карта жизненного цикла

\n

Документация Bitrix говорит важную вещь: перед добавлением вызывается OnBeforeIBlockElementAdd. Обработчик получает поля по ссылке, поэтому способен их изменить; чтобы отменить запись, он должен установить исключение через $APPLICATION->ThrowException() и вернуть false. После успешной записи срабатывают события после добавления. Это значит, что обработчик — реальная часть контракта метода, а не декоративная «магия в init.php».

\n
\"Последовательность
ID возвращается из слоя инфоблока, но качество результата подтверждается уже в пользовательском сценарии.
\n

Где живёт каждое правило

\n
МестоХорошая ответственностьЧто туда не стоит класть
Сервис созданияПроверка входа, подготовка полей, перевод ошибки в понятный результатГлобальные побочные эффекты для любого инфоблока
OnBeforeIBlockElementAddПоследний общий барьер: запрет пустого CODE, аудит общей политикиВнешние HTTP-вызовы, тяжёлую обработку файлов, правила одного экрана
После записиОтправка события, фоновая реакция, журналирование успешной операцииИзменение результата, от которого зависит успех текущего Add
Публичный компонентФильтрация и отображение данныхИсправление отсутствующих обязательных данных «на лету»
\n

Минимальный предохранитель в событии

\n

Ниже — не замена сервису, а общий барьер для конкретного инфоблока. Он предотвращает запись элемента без символьного кода независимо от того, откуда пришёл вызов: админка, импорт или самописный endpoint. Важно, что код не пытается угадать всё бизнес-правило товара. Он проверяет только инвариант, который действительно должен быть общим.

\n
<?php\n\nconst PRODUCT_IBLOCK_ID = 12;\n\nAddEventHandler(\n    "iblock",\n    "OnBeforeIBlockElementAdd",\n    ["CatalogElementGuard", "beforeAdd"]\n);\n\nfinal class CatalogElementGuard\n{\n    public static function beforeAdd(array &$fields): bool\n    {\n        if ((int)($fields["IBLOCK_ID"] ?? 0) !== PRODUCT_IBLOCK_ID) {\n            return true;\n        }\n\n        if (trim((string)($fields["CODE"] ?? "")) === "") {\n            global $APPLICATION;\n            $APPLICATION->ThrowException("Для товара нужен символьный код");\n            return false;\n        }\n\n        return true;\n    }\n}
\n

Почему событие не должно быть единственным валидатором

\n

Потому что событие не знает намерения конкретной операции. Один экран может создавать черновик без картинки, другой — импортировать поставщика, третий — мигрировать старые записи. Если все проверки спрятать в OnBeforeIBlockElementAdd, получится глобальная функция с десятком условий и неожиданными побочными эффектами. Сервис создания должен объяснять, почему он принимает или отклоняет вход. Событие лишь страхует инвариант, который действует для всех.

\n

Сервис остаётся точкой диагностики

\n
<?php\n\nfunction addCatalogElement(array $fields): int\n{\n    if (!\\Bitrix\\Main\\Loader::includeModule("iblock")) {\n        throw new RuntimeException("Модуль iblock не подключён");\n    }\n\n    $element = new CIBlockElement();\n    $id = $element->Add($fields);\n\n    if ($id === false) {\n        $message = $element->LAST_ERROR ?: "Bitrix не вернул причину ошибки";\n        throw new RuntimeException($message);\n    }\n\n    return (int)$id;\n}
\n

После записи — это уже другой разговор

\n

Обработчик после добавления удобен для журналирования, запуска поиска или отправки внутреннего уведомления. Но он не должен молча решать судьбу уже созданного элемента. Если побочный шаг упал после того, как Add вернул ID, повторный вызов Add из обработчика легко создаст дубль, а внешний сервис получит два одинаковых запроса. Поэтому после записи я бы сохранял ID, внешний ключ и понятный статус операции, а ошибку реакции разбирал как отдельную задачу.

\n

Если синхронизация с внешней системой действительно обязательна для публикации товара, полезно разделить два состояния: «элемент сохранён» и «элемент готов для пользователя». Первый факт подтверждает сервис создания, второй — контрольная проверка после всех зависимых действий. Тогда временный сбой индексации или уведомления не превращается в неясную историю, где никто не понимает, можно ли безопасно повторить импорт.

\n

Три вопроса до запуска

\n
  1. Какой инвариант действительно общий для всех способов создания элемента, а какой относится только к форме или импорту?
  2. Где вызывающий код получит причину отказа: в результате сервиса, в LAST_ERROR или в отдельном журнале операции?
  3. Как повторный запуск отличит новую запись от уже созданной и не превратит сбой обработчика в дубликат?
\n

Как тестировать такую связку

\n

В 2018-м легко ограничиться ручной проверкой в админке, но здесь полезно хотя бы зафиксировать короткую матрицу. Она не требует сложного тестового фреймворка: часть сценариев можно выполнить на тестовом инфоблоке и сохранить как чек-лист релиза. Главное — проверять и прямой сервис, и поведение глобального события.

\n
СценарийОжиданиеГде искать ошибку при сбое
Корректный элементСервис возвращает ID, элемент читаетсяПоля сервиса и конфигурация инфоблока
Пустой CODEЗапись отменена, причина понятна вызывающему кодуОбработчик OnBeforeIBlockElementAdd
Другой инфоблокОхранник не вмешиваетсяСлишком широкое условие в обработчике
Импорт или CLIРезультат тот же, что из формыСкрытая зависимость от HTTP-сессии или интерфейса
\n

Проверяемые источники

\n
  • CIBlockElement::Add — контракт метода, обработчики до и после записи, ID и LAST_ERROR
  • OnBeforeIBlockElementAdd — как обработчик может изменить поля или отменить запись
  • CIBlockElement::SetPropertyValuesEx — точечное сохранение свойств и особенности пустых значений
\n

Итог

\n

События Bitrix полезны, когда их граница ясна. Общий инвариант — в обработчик. Намерение операции, логирование и перевод ошибки — в сервис. Публичная видимость — в отдельную проверку после создания. С такой схемой даже старый проект перестаёт выглядеть набором случайных init.php-заклинаний: у каждого правила появляется место и причина.

", "readingMinutes": 9 }, { @@ -5032,7 +5035,7 @@ ], "cover": "/assets/editorial/2018/bitrix-catalog-workflow.png", "excerpt": "Разбираем создание элемента инфоблока как полноценную операцию: контракт полей, обработка LAST_ERROR, свойства, контрольная выборка и проверка публичного сценария.", - "contentHtml": "

Иногда задача формулируется очень просто: «добавь товар через API». Первая версия обычно занимает десять строк — создаём CIBlockElement, вызываем Add, получаем ID. А через день приходит сообщение: товар есть в админке, но карточка пустая, ссылка ведёт не туда или импорт тихо пропустил половину ошибок. Давайте сразу сделаем операцию так, чтобы её можно было проверить, повторить и поддерживать.

\n

Ситуация: ID — это ещё не готовый результат

\n

Элемент инфоблока — лишь одна часть пользовательского сценария. Для каталога могут быть важны символьный код, раздел, обязательные свойства, активность, картинка, цена и остаток. Метод CIBlockElement::Add действительно возвращает ID при успехе и false при ошибке, а текст причины лежит в LAST_ERROR. Поэтому нормальный критерий готовности состоит из двух вопросов: запись создана и потребитель этой записи видит ожидаемые данные.

\n
\"Разработчик
Не начинаем с большого импорта. Сначала рисуем путь данных и называем контрольные точки.
\n

Сначала формулируем контракт операции

\n

Перед вызовом API полезно выписать, какие поля обязательны именно для нашего инфоблока. Это выглядит занудно до первой ошибки импорта, а после неё экономит часы. Не нужно создавать универсальный валидатор Bitrix: достаточно проверить входные данные, зафиксировать системные значения и вернуть диагностируемую ошибку вызывающему коду.

\n
УчастокЧто фиксируемЧем доказываем
Входname, внешний ID, категория, файлыВалидация до вызова Bitrix и понятная ошибка для вызывающего кода
ЭлементIBLOCK_ID, NAME, CODE, ACTIVEМассив $fields можно залогировать без секретов
СвойстваКакие свойства обязательны при первом сохраненииОни передаются в PROPERTY_VALUES или проверяются отдельно
РезультатID, URL, видимость в нужной выборкеКонтрольный запрос и тест пользовательского сценария
\n

Рабочий пример

\n

Ниже пример для черновика товара. Я намеренно сохраняю элемент неактивным: пока импорт не завершил все обязательные действия, пользователю незачем видеть полуготовую карточку. Конкретные коды свойств и ID инфоблока должны быть вынесены в конфигурацию проекта, а не спрятаны в середине функции.

\n
<?php\n\nuse Bitrix\\Main\\Loader;\n\nconst PRODUCT_IBLOCK_ID = 12;\n\nfunction createProductDraft(array $input): int\n{\n    if (!Loader::includeModule("iblock")) {\n        throw new RuntimeException("Модуль iblock не подключён");\n    }\n\n    $name = trim((string)($input["name"] ?? ""));\n    $code = trim((string)($input["code"] ?? ""));\n\n    if ($name === "" || $code === "") {\n        throw new InvalidArgumentException("Нужны NAME и CODE");\n    }\n\n    $element = new CIBlockElement();\n    $id = $element->Add([\n        "IBLOCK_ID" => PRODUCT_IBLOCK_ID,\n        "NAME" => $name,\n        "CODE" => $code,\n        "ACTIVE" => "N",\n        "PROPERTY_VALUES" => [\n            "EXTERNAL_ID" => (string)($input["externalId"] ?? ""),\n            "BRAND" => (int)($input["brandId"] ?? 0),\n        ],\n    ]);\n\n    if ($id === false) {\n        throw new RuntimeException($element->LAST_ERROR ?: "Не удалось создать элемент");\n    }\n\n    return (int)$id;\n}
\n

Почему свойства лучше не «доклеивать» вслепую

\n

Для обязательных свойств, без которых объект не имеет смысла, удобнее передавать PROPERTY_VALUES в том же вызове Add. Метод SetPropertyValuesEx полезен, когда нужно сознательно обновить небольшую часть свойств: он не требует передавать полный набор и экономнее по запросам. Но он возвращает null, поэтому его нельзя использовать как удобный индикатор успеха. Если частичное обновление критично, его надо окружить собственным журналированием и контрольным чтением.

\n
<?php\n\n// Осознанное точечное изменение, а не «попробуем и забудем».\nCIBlockElement::SetPropertyValuesEx(\n    $elementId,\n    PRODUCT_IBLOCK_ID,\n    ["SYNC_STATUS" => "ready"]\n);\n\n// После важного изменения читаем нужное свойство в контрольном сценарии.
\n

Четыре проверки после Add

\n
  1. Проверяем, что вернулся положительный ID; при false сохраняем LAST_ERROR, входной внешний идентификатор и контекст операции.
  2. Читаем элемент в том же инфоблоке и убеждаемся, что поля NAME, CODE и нужные свойства действительно сохранены.
  3. Проверяем публичную выборку с теми же фильтрами, которые использует компонент каталога: активность, даты, раздел, права, цена и остатки — если они участвуют в сценарии.
  4. Только после этого включаем элемент или помечаем импортированную запись как готовую.
\n

Чего я бы не делал

\n
  • Не игнорировал бы результат Add в надежде, что ошибка «сама попадёт в журнал».
  • Не делал бы элемент активным до заполнения зависимых данных.
  • Не генерировал бы CODE без правила уникальности: два одинаковых названия неизбежно встретятся.
  • Не очищал бы весь кеш первым действием. Сначала нужно доказать, что проблема именно в кеше, а не в данных или фильтре.
\n

Проверяемые источники

\n\n

Итог

\n

Сам вызов CIBlockElement::Add несложен. Сложность в том, чтобы не потерять границу между «запись появилась» и «сценарий закончен». Если хранить контракт полей рядом с кодом, проверять LAST_ERROR и делать контрольную выборку, импорт перестаёт быть магией. А дальше уже можно спокойно добавлять цены, остатки и любые проектные правила.

", + "contentHtml": "

Иногда задача формулируется очень просто: «добавь товар через API». Первая версия обычно занимает десять строк — создаём CIBlockElement, вызываем Add, получаем ID. А через день приходит сообщение: товар есть в админке, но карточка пустая, ссылка ведёт не туда или импорт тихо пропустил половину ошибок. Давайте сразу сделаем операцию так, чтобы её можно было проверить, повторить и поддерживать.

\n

Ситуация: ID — это ещё не готовый результат

\n

Элемент инфоблока — лишь одна часть пользовательского сценария. Для каталога могут быть важны символьный код, раздел, обязательные свойства, активность, картинка, цена и остаток. Метод CIBlockElement::Add действительно возвращает ID при успехе и false при ошибке, а текст причины лежит в LAST_ERROR. Поэтому нормальный критерий готовности состоит из двух вопросов: запись создана и потребитель этой записи видит ожидаемые данные.

\n
\"Разработчик
Не начинаем с большого импорта. Сначала рисуем путь данных и называем контрольные точки.
\n

Сначала формулируем контракт операции

\n

Перед вызовом API полезно выписать, какие поля обязательны именно для нашего инфоблока. Это выглядит занудно до первой ошибки импорта, а после неё экономит часы. Не нужно создавать универсальный валидатор Bitrix: достаточно проверить входные данные, зафиксировать системные значения и вернуть диагностируемую ошибку вызывающему коду.

\n

Ещё один пункт контракта — повторный запуск. Импорт может оборваться после ответа базы или до записи в журнал. Поэтому внешний идентификатор должен позволять отличить новую операцию от повтора. В проекте это обычно означает отдельную проверку существующей записи по внешнему ключу и явное правило: обновляем черновик, пропускаем готовую запись или останавливаемся с конфликтом. Сам Add за это правило не отвечает.

\n
УчастокЧто фиксируемЧем доказываем
Входname, внешний ID, категория, файлыВалидация до вызова Bitrix и понятная ошибка для вызывающего кода
ЭлементIBLOCK_ID, NAME, CODE, ACTIVEМассив $fields можно залогировать без секретов
СвойстваКакие свойства обязательны при первом сохраненииОни передаются в PROPERTY_VALUES или проверяются отдельно
РезультатID, URL, видимость в нужной выборкеКонтрольный запрос и тест пользовательского сценария
\n

Рабочий пример

\n

Ниже пример для черновика товара. Я намеренно сохраняю элемент неактивным: пока импорт не завершил все обязательные действия, пользователю незачем видеть полуготовую карточку. Конкретные коды свойств и ID инфоблока должны быть вынесены в конфигурацию проекта, а не спрятаны в середине функции.

\n
<?php\n\nuse Bitrix\\Main\\Loader;\n\nconst PRODUCT_IBLOCK_ID = 12;\n\nfunction createProductDraft(array $input): int\n{\n    if (!Loader::includeModule("iblock")) {\n        throw new RuntimeException("Модуль iblock не подключён");\n    }\n\n    $name = trim((string)($input["name"] ?? ""));\n    $code = trim((string)($input["code"] ?? ""));\n\n    if ($name === "" || $code === "") {\n        throw new InvalidArgumentException("Нужны NAME и CODE");\n    }\n\n    $element = new CIBlockElement();\n    $id = $element->Add([\n        "IBLOCK_ID" => PRODUCT_IBLOCK_ID,\n        "NAME" => $name,\n        "CODE" => $code,\n        "ACTIVE" => "N",\n        "PROPERTY_VALUES" => [\n            "EXTERNAL_ID" => (string)($input["externalId"] ?? ""),\n            "BRAND" => (int)($input["brandId"] ?? 0),\n        ],\n    ]);\n\n    if ($id === false) {\n        throw new RuntimeException($element->LAST_ERROR ?: "Не удалось создать элемент");\n    }\n\n    return (int)$id;\n}
\n

Почему свойства лучше не «доклеивать» вслепую

\n

Для обязательных свойств, без которых объект не имеет смысла, удобнее передавать PROPERTY_VALUES в том же вызове Add. Метод SetPropertyValuesEx полезен, когда нужно сознательно обновить небольшую часть свойств: он не требует передавать полный набор и экономнее по запросам. Но он возвращает null, поэтому его нельзя использовать как удобный индикатор успеха. Если частичное обновление критично, его надо окружить собственным журналированием и контрольным чтением.

\n
<?php\n\n// Осознанное точечное изменение, а не «попробуем и забудем».\nCIBlockElement::SetPropertyValuesEx(\n    $elementId,\n    PRODUCT_IBLOCK_ID,\n    ["SYNC_STATUS" => "ready"]\n);\n\n// После важного изменения читаем нужное свойство в контрольном сценарии.
\n

Четыре проверки после Add

\n
  1. Проверяем, что вернулся положительный ID; при false сохраняем LAST_ERROR, входной внешний идентификатор и контекст операции.
  2. Читаем элемент в том же инфоблоке и убеждаемся, что поля NAME, CODE и нужные свойства действительно сохранены.
  3. Проверяем публичную выборку с теми же фильтрами, которые использует компонент каталога: активность, даты, раздел, права, цена и остатки — если они участвуют в сценарии.
  4. Только после этого включаем элемент или помечаем импортированную запись как готовую.
\n

Чего я бы не делал

\n
  • Не игнорировал бы результат Add в надежде, что ошибка «сама попадёт в журнал».
  • Не делал бы элемент активным до заполнения зависимых данных.
  • Не генерировал бы CODE без правила уникальности: два одинаковых названия неизбежно встретятся.
  • Не очищал бы весь кеш первым действием. Сначала нужно доказать, что проблема именно в кеше, а не в данных или фильтре.
\n

Проверяемые источники

\n\n

Итог

\n

Сам вызов CIBlockElement::Add несложен. Сложность в том, чтобы не потерять границу между «запись появилась» и «сценарий закончен». Если хранить контракт полей рядом с кодом, проверять LAST_ERROR и делать контрольную выборку, импорт перестаёт быть магией. А дальше уже можно спокойно добавлять цены, остатки и любые проектные правила.

", "readingMinutes": 9 }, { diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs new file mode 100644 index 0000000..d0838e1 --- /dev/null +++ b/web/data/editorial-revisions.mjs @@ -0,0 +1,8 @@ +import { revisions as march2018Revisions } from '../scripts/upgrade-2018-03.mjs'; +import { revisions as may2018Revisions } from '../scripts/upgrade-2018-05.mjs'; + +// This layer replaces archived source entries without losing their stable slug and date. +export const editorialRevisions = [ + ...march2018Revisions, + ...may2018Revisions, +]; diff --git a/web/lib/articles.js b/web/lib/articles.js index 960e64a..9aca557 100644 --- a/web/lib/articles.js +++ b/web/lib/articles.js @@ -1,11 +1,21 @@ import articles from '../data/articles.json'; +import { editorialRevisions } from '../data/editorial-revisions.mjs'; + +const revisionBySlug = new Map( + editorialRevisions.map((revision) => [revision.slug, revision]), +); + +const publishedArticles = articles.map((article) => ({ + ...article, + ...revisionBySlug.get(article.slug), +})); export function getArticles() { - return articles; + return publishedArticles; } export function getArticleBySlug(slug) { - return articles.find((article) => article.slug === slug); + return publishedArticles.find((article) => article.slug === slug); } export function formatDate(date) { diff --git a/web/public/assets/editorial/2018/bitrix-slug-build-2018.svg b/web/public/assets/editorial/2018/bitrix-slug-build-2018.svg new file mode 100644 index 0000000..2d34c2d --- /dev/null +++ b/web/public/assets/editorial/2018/bitrix-slug-build-2018.svg @@ -0,0 +1,66 @@ + + Построение символьного кода элемента Bitrix + Схема показывает путь от названия элемента через транслитерацию и проверку занятости кода к сохранению элемента или добавлению суффикса. + + + + + + + + Символьный код — это цепочка проверок, а не только транслит + + + 1. Имя + «Кофе Classic + 250 г» + + + + + 2. CUtil:: + translit + нижний регистр, + дефис вместо пробела + + + + + 3. Кандидат + kofe-classic-250-g + + + + + 4. GetList + IBLOCK_ID + + CODE + есть запись? + + + + Свободен + передаём в Add + + + + Занят + добавляем -2, -3… + + + нет + да + Проверка по списку полезна для последовательного ввода. При параллельном импорте нужен отдельный проектный способ исключить гонку. + diff --git a/web/public/assets/editorial/2018/bitrix-slug-conflict-2018.svg b/web/public/assets/editorial/2018/bitrix-slug-conflict-2018.svg new file mode 100644 index 0000000..d5b75fd --- /dev/null +++ b/web/public/assets/editorial/2018/bitrix-slug-conflict-2018.svg @@ -0,0 +1,54 @@ + + Диагностика конфликта символьного кода Bitrix + Дерево проверки для ситуации, когда адрес карточки открывает не тот элемент: путь, переменные, выборка и набор совпадений. + + + + + + + + Карточка открывает не тот товар: сначала считаем совпадения + + + Ожидаемый URL + .../classic-250-g/ + + + + Что компонент получил? + ELEMENT_CODE = classic-250-g + + + + GetList по этому CODE + в том же IBLOCK_ID и с теми же фильтрами + + + + 0 записей + шаблон, фильтр, активность + + + + 2+ записи + конфликт CODE или фильтр + + + + 1 запись: сверяем ID + + Кеш проверяем после данных и маршрута. Иначе он становится удобным, но недоказанным объяснением. + diff --git a/web/public/assets/editorial/2018/bitrix-slug-route-2018.svg b/web/public/assets/editorial/2018/bitrix-slug-route-2018.svg new file mode 100644 index 0000000..a7d0b6c --- /dev/null +++ b/web/public/assets/editorial/2018/bitrix-slug-route-2018.svg @@ -0,0 +1,64 @@ + + Путь URL до элемента инфоблока в Bitrix + Диаграмма показывает как адрес страницы разбирается шаблоном SEF, превращается в переменные и используется для поиска элемента инфоблока. + + + + + + + + Адрес не ищет запись сам: компонент сначала восстанавливает переменные + + + Запрос браузера + /catalog/kofe/ + classic-250-g/ + + + + + SEF-шаблон + #SECTION_CODE#/ + #ELEMENT_CODE#/ + ParseComponentPath + + + + + Переменные + SECTION_CODE + ELEMENT_CODE + не запись в БД + + + + + Query + CODE + + filter + + + + Элемент найден + детальная страница + + + + Нет совпадения + 404 или другой путь + + Если шаблон, имя переменной и фильтр расходятся, менять CODE бесполезно: сначала надо увидеть, какое значение восстановлено из URL. + diff --git a/web/public/assets/editorial/2018/curl-outcome-classifier.svg b/web/public/assets/editorial/2018/curl-outcome-classifier.svg new file mode 100644 index 0000000..5d1e9fb --- /dev/null +++ b/web/public/assets/editorial/2018/curl-outcome-classifier.svg @@ -0,0 +1,46 @@ + + Три уровня результата cURL-интеграции + После curl_exec сначала проверяется false и ошибка транспорта, затем код HTTP, а затем контракт тела ответа. + + + + + + + + + + + cURL отвечает за передачу, а не за успех операции + Проверки идут слева направо: транспорт → HTTP → полезная нагрузка + + + curl_exec() + получить тело + + + body === false? + cURL + сеть + + да + + transport error + errno, error, time + + нет + + HTTP 2xx? + curl_getinfo + + нет + + HTTP error + status + route + + да + + Тело + контракт + + Статус 404 или 500 может прийти как строка: это не false для curl_exec(). + diff --git a/web/public/assets/editorial/2018/jquery-ajax-form-contract.svg b/web/public/assets/editorial/2018/jquery-ajax-form-contract.svg new file mode 100644 index 0000000..1b88ac2 --- /dev/null +++ b/web/public/assets/editorial/2018/jquery-ajax-form-contract.svg @@ -0,0 +1,73 @@ + + Контракт Ajax-формы в legacy jQuery + Диаграмма показывает состояния формы: готова, запрос отправлен, успешный или ошибочный ответ, затем обязательное освобождение интерфейса в обработчике always. + + + + + + + + + Ajax-форма: интерфейс возвращается + в готовое состояние + Клиентский флаг убирает повторный submit в текущем DOM, а always освобождает кнопку. + + + 1. Готова + Кнопка доступна, + запроса нет. + + + + + 2. submit + data('request') + + prop('disabled') + Один jqXHR хранится на форме. + Повторный submit выходит сразу. + + + + + 3. jqXHR + Серверный ответ + или ошибка сети. + + + + + + 4a. done + Показать подтверждённый + результат сервера. + + + 4b. fail + Показать ошибку и не + считать отправку успехом. + + + + + + always: removeData + + disabled(false) + + Ограничение: клиент не заменяет серверную защиту от повторной операции. + diff --git a/web/public/assets/editorial/2018/jquery-delegation-after-html.svg b/web/public/assets/editorial/2018/jquery-delegation-after-html.svg new file mode 100644 index 0000000..d4855a7 --- /dev/null +++ b/web/public/assets/editorial/2018/jquery-delegation-after-html.svg @@ -0,0 +1,69 @@ + + Прямая и делегированная привязка событий после замены HTML + Сравнение двух подходов: прямой обработчик находится на кнопке и исчезает при замене содержимого контейнера, делегированный обработчик остаётся на постоянном контейнере и получает события от новой кнопки. + + + + + + + + + + + + После container.html(): что остаётся? + Событие переживает замену DOM только тогда, когда привязано к постоянному предку. + + + Прямая привязка к кнопке + $('.js-remove') + .on('click', handler) + + + #cart получает новый HTML + + старая кнопка + + обработчик жил + на дочернем узле + + + Новая кнопка создана без него. + + + Делегирование от #cart + $('#cart').on('click.cart', + '.js-remove', handler) + + + #cart остаётся в DOM + + обработчик на контейнере + + новая кнопка + + Клик всплывает до #cart. + Перепривязывать кнопку не нужно. + + + Корень — ближайший постоянный контейнер. + diff --git a/web/public/assets/editorial/2018/jquery-reinit-namespaces.svg b/web/public/assets/editorial/2018/jquery-reinit-namespaces.svg new file mode 100644 index 0000000..ec9b6bc --- /dev/null +++ b/web/public/assets/editorial/2018/jquery-reinit-namespaces.svg @@ -0,0 +1,58 @@ + + Повторная инициализация jQuery-виджета без дублирования обработчиков + Схема показывает, как повторный вызов функции инициализации снимает только свои обработчики через пространство имён и затем назначает один новый обработчик. + + + + + + + + + Повторный mount: один обработчик + при любом числе вызовов + Пространство имён отделяет события виджета от остального legacy-кода. + + + 1. mount(root) + Виджет вызывают + после Ajax, таба + или повторного рендера. + + + + + 2. Снять только свои + .off('.orderForm') + Соседние click-события + не трогаем. + + + + + 3. Назначить один + .on('click.orderForm') + Новый обработчик + предсказуемо один. + + + + Проверка: три вызова mount() → одна отправка формы. + Это свойство функции инициализации, а не удача порядка загрузки. + + Схема для статьи о jQuery 3.x и legacy-интерфейсе, май 2018. + diff --git a/web/public/assets/editorial/2018/json-payload-diagnostic.svg b/web/public/assets/editorial/2018/json-payload-diagnostic.svg new file mode 100644 index 0000000..8d11931 --- /dev/null +++ b/web/public/assets/editorial/2018/json-payload-diagnostic.svg @@ -0,0 +1,44 @@ + + Диагностика JSON-ответа API + Сырой ответ разбирается функцией json_decode, затем проверяется json_last_error, а при успехе — тип и обязательные поля контракта. + + + + + + + + + + + «null» и ошибка синтаксиса — не один случай + Сначала проверяем корректность JSON, потом — договор конкретного endpoint + + + Сырой ответ + строка + размер + + + json_decode + без догадок + + + json_last_error + JSON_ERROR_NONE? + + нет + + decode error + код + хеш тела + + да + + Контракт + тип и поля + + + contract error + валидный JSON + + Не пишем тело в общий журнал: размер и SHA-256 достаточно сравнить два ответа. + diff --git a/web/public/assets/editorial/2018/php-fatal-context-flow.svg b/web/public/assets/editorial/2018/php-fatal-context-flow.svg new file mode 100644 index 0000000..75db010 --- /dev/null +++ b/web/public/assets/editorial/2018/php-fatal-context-flow.svg @@ -0,0 +1,51 @@ + + Диагностика PHP-ошибки в интеграции + Контекст операции создаётся перед вызовом партнёра и соединяется с тремя ветками обработки: warning, непойманное исключение и фатальная ошибка при завершении PHP. + + + + + + + + + + + + + + Контекст не должен появляться после падения + request ID и этап операции создаются до внешнего вызова + + + Операция + request_id + stage + + + + + + + + + warning / notice + set_error_handler + + Throwable + set_exception_handler + + fatal error + shutdown + error_get_last + + + + + + + Лог + один факт + для разбора + + Не пишем: пароли, токены, полный запрос и полный ответ партнёра. + diff --git a/web/public/assets/editorial/2018/php-private-download-flow.svg b/web/public/assets/editorial/2018/php-private-download-flow.svg new file mode 100644 index 0000000..4a80cd3 --- /dev/null +++ b/web/public/assets/editorial/2018/php-private-download-flow.svg @@ -0,0 +1,76 @@ + + Выдача приватного документа через PHP + Последовательность запроса: браузер, маршрут приложения, база документов, закрытое хранилище, HTTP-ответ. + + + + + + + + + + + + + + + + + + Приватный файл: доступ проверяется до чтения с диска + URL содержит ID записи. Настоящий ключ и путь остаются за маршрутом приложения. + + + + Браузер + ПОЛЬЗОВАТЕЛЬ + + + Маршрут PHP + АВТОРИЗАЦИЯ + + + documents + БАЗА ДАННЫХ + + + Закрытый каталог + ФАЙЛОВАЯ СИСТЕМА + + + + + + + + + 1. GET + /documents/42/download + + + 2. Проверка владельца + id + owner_id + status + + + 3. Найден ключ + storage_key.pdf + + + 4. PHP читает только закрытый путь + + + 5. HTTP-ответ + Content-Type: application/pdf + + + Прямого URL к каталогу нет: запись в базе связывает пользователя с ключом, а маршрут решает, можно ли читать файл. + diff --git a/web/public/assets/editorial/2018/php-upload-avatar-contract.svg b/web/public/assets/editorial/2018/php-upload-avatar-contract.svg new file mode 100644 index 0000000..d45dff0 --- /dev/null +++ b/web/public/assets/editorial/2018/php-upload-avatar-contract.svg @@ -0,0 +1,91 @@ + + Контракт безопасной загрузки аватара + Схема из четырёх шагов: браузер, временный файл PHP, проверка, закрытое хранилище. + + + + + + + + + + + + + + + + Загрузка аватара: решение принимается до переноса + Клиент присылает файл. Приложение выбирает допустимый тип, размеры и собственный ключ. + + + + + + + + + + 1 + КЛИЕНТ + Форма + name=avatar + имя и Content-Type + остаются входом, + а не решением. + + + + + + + 2 + PHP + Временный файл + $_FILES + error · size · tmp_name + Сначала убеждаемся, + что доставка завершилась. + + + + + + + 3 + ПРОВЕРКА + Контракт + finfo_file() + JPEG / PNG · 2 МБ + размеры изображения + и белый список. + + + + + + + 4 + ХРАНИЛИЩЕ + Закрытый каталог + 7f4a...c2.png + свой ключ приложения, + никакого пути из + исходного имени. + + + + + + Перенос происходит только после проверки: из браузера в каталог попадает не «файл с именем», а запись, соответствующая контракту. + diff --git a/web/public/assets/editorial/2018/php-upload-trust-signals.svg b/web/public/assets/editorial/2018/php-upload-trust-signals.svg new file mode 100644 index 0000000..b0762d3 --- /dev/null +++ b/web/public/assets/editorial/2018/php-upload-trust-signals.svg @@ -0,0 +1,86 @@ + + Границы доверия при загрузке файла + Диаграмма показывает клиентские метаданные, результат PHP, Fileinfo и решение приложения. + + + + + + + + + + + + + + + + + + Загрузка — это не один сигнал, а несколько границ + Слева — то, что заявил клиент. Справа — то, на чём приложение строит решение. + + СВЕДЕНИЯ КЛИЕНТА + РЕЗУЛЬТАТ PHP + РЕШЕНИЕ ПРИЛОЖЕНИЯ + + + + + + + Имя и расширение + avatar.jpg + Удобны для интерфейса. + + + Content-Type + image/jpeg + Это поле multipart-части. + + + + + Доставка + UPLOAD_ERR_OK + PHP принял файл целиком. + + + Временный файл + tmp_name · size + Есть что проверять на сервере. + + + + + Fileinfo + finfo_file() + Определяет тип временного файла. + + + Белый список + JPEG / PNG / 2 МБ + Правило конкретного сценария. + + + + + + + + + + + Практическое правило + Имя и клиентский MIME-тип не определяют допуск. Они становятся полезными только после того, как серверный анализ и белый список дали ответ. + Отдельная проверка размеров дополняет контракт изображения, но не заменяет проверку типа. + diff --git a/web/scripts/audit-quality-batch.mjs b/web/scripts/audit-quality-batch.mjs index 5631a33..774a48f 100644 --- a/web/scripts/audit-quality-batch.mjs +++ b/web/scripts/audit-quality-batch.mjs @@ -1,21 +1,38 @@ import { access, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { editorialRevisions } from '../data/editorial-revisions.mjs'; const webRoot = join(fileURLToPath(new URL('..', import.meta.url))); const articlesPath = join(webRoot, 'data', 'articles.json'); -const slugs = process.argv.slice(2); +const archivedArticles = JSON.parse(await readFile(articlesPath, 'utf8')); +const revisionBySlug = new Map( + editorialRevisions.map((revision) => [revision.slug, revision]), +); +const archive = archivedArticles.map((article) => ({ + ...article, + ...revisionBySlug.get(article.slug), +})); +const requestedSlugs = process.argv.slice(2); +const slugs = requestedSlugs.includes('--all-editorial') + ? archive.filter((article) => article.slug.startsWith('editorial-')).map((article) => article.slug) + : requestedSlugs; if (slugs.length === 0) { - throw new Error('Usage: node scripts/audit-quality-batch.mjs [...slug]'); + throw new Error('Usage: node scripts/audit-quality-batch.mjs [...slug] | --all-editorial'); } - -const archive = JSON.parse(await readFile(articlesPath, 'utf8')); const genericPhrases = [ 'У этой модели нет магической силы', 'Материалы для проверки', 'Если держать этот порядок, решение остаётся понятным', + 'В современном мире', + 'очень важно', + 'следует отметить', + 'просто нужно', + 'нужно понимать, что', ]; +const MIN_BODY_CHARS = 5000; +const MAX_BODY_CHARS = 15000; let failed = false; function count(content, expression) { @@ -30,6 +47,12 @@ function plainText(content) { .trim(); } +function bodyText(content) { + return plainText( + content.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, ''), + ); +} + for (const slug of slugs) { const article = archive.find((candidate) => candidate.slug === slug); const issues = []; @@ -41,19 +64,43 @@ for (const slug of slugs) { } const content = article.contentHtml; - const text = plainText(content); + const body = bodyText(content); const imageSources = [...content.matchAll(/]+src="([^"]+)"/g)].map((match) => match[1]); + const figures = [...content.matchAll(/
([\s\S]*?)<\/figure>/g)].map((match) => match[1]); if (article.readingMinutes < 8) issues.push('указано меньше 8 минут чтения'); - if (text.length < 4800) issues.push('меньше 4800 символов осмысленного текста'); + if (body.length < MIN_BODY_CHARS) { + issues.push('меньше ' + MIN_BODY_CHARS + ' знаков основного текста'); + } + if (body.length > MAX_BODY_CHARS) { + issues.push('больше ' + MAX_BODY_CHARS + ' знаков основного текста'); + } if (count(content, /

/g) < 5) issues.push('меньше пяти смысловых разделов'); if (count(content, /
/g) < 1 || imageSources.length < 1) issues.push('нет визуального объяснения'); if (count(content, /
/g) < 1) issues.push('у иллюстрации нет подписи'); if (count(content, //g) < 1 || count(content, //g) < 1) issues.push('нет доступной таблицы'); if (count(content, /
/g) < 1) issues.push('нет воспроизводимого примера');
+  if (count(content, /
    /g) < 1) issues.push('нет последовательности проверки или действий'); if (count(content, //g) + ' figure, ' + count(content, /
/g) + ' table, ' + count(content, /
/g) + ' code example',
diff --git a/web/scripts/upgrade-2018-01.mjs b/web/scripts/upgrade-2018-01.mjs
index d40b41c..fa7ff13 100644
--- a/web/scripts/upgrade-2018-01.mjs
+++ b/web/scripts/upgrade-2018-01.mjs
@@ -92,6 +92,7 @@ const practiceArticle = {
     figure('/assets/editorial/2018/bitrix-catalog-workflow.png', 'Разработчик проверяет путь от формы к карточке товара и фиксирует схему процесса', 'Не начинаем с большого импорта. Сначала рисуем путь данных и называем контрольные точки.'),
     heading('Сначала формулируем контракт операции'),
     paragraph('Перед вызовом API полезно выписать, какие поля обязательны именно для нашего инфоблока. Это выглядит занудно до первой ошибки импорта, а после неё экономит часы. Не нужно создавать универсальный валидатор Bitrix: достаточно проверить входные данные, зафиксировать системные значения и вернуть диагностируемую ошибку вызывающему коду.'),
+    paragraph('Ещё один пункт контракта — повторный запуск. Импорт может оборваться после ответа базы или до записи в журнал. Поэтому внешний идентификатор должен позволять отличить новую операцию от повтора. В проекте это обычно означает отдельную проверку существующей записи по внешнему ключу и явное правило: обновляем черновик, пропускаем готовую запись или останавливаемся с конфликтом. Сам Add за это правило не отвечает.'),
     dataTable(
       ['Участок', 'Что фиксируем', 'Чем доказываем'],
       [
@@ -185,7 +186,7 @@ const mechanismArticle = {
   excerpt: 'Разбираем жизненный цикл добавления элемента: кто проверяет поля, где срабатывают события Bitrix, почему глобальный обработчик не заменяет сервис и как тестировать эту границу.',
   readingMinutes: 9,
   contentHtml: [
-    paragraph('Когда Bitrix-проект разрастается, вокруг простого CIBlockElement::Add появляется невидимый код: обработчики событий, правила символьного кода, импортеры, каталог, поиск и шаблоны. Из-за этого одинаковый вызов сегодня работает из формы, а завтра падает из консольного скрипта. Давайте разложим путь записи по шагам и не будем прятать бизнес-правило в месте, где его трудно обнаружить.'),
+    paragraph('Проблема появляется, когда Bitrix-проект разрастается вокруг простого CIBlockElement::Add: обработчики событий, правила символьного кода, импортеры, каталог, поиск и шаблоны становятся невидимой частью вызова. Из-за этого одинаковый код сегодня работает из формы, а завтра падает из консольного скрипта. Давайте разложим путь записи по шагам и не будем прятать бизнес-правило в месте, где его трудно обнаружить.'),
     heading('Карта жизненного цикла'),
     paragraph('Документация Bitrix говорит важную вещь: перед добавлением вызывается OnBeforeIBlockElementAdd. Обработчик получает поля по ссылке, поэтому способен их изменить; чтобы отменить запись, он должен установить исключение через $APPLICATION->ThrowException() и вернуть false. После успешной записи срабатывают события после добавления. Это значит, что обработчик — реальная часть контракта метода, а не декоративная «магия в init.php».'),
     figure('/assets/editorial/2018/bitrix-add-lifecycle.svg', 'Последовательность от формы до контрольной публичной выборки при создании элемента Bitrix', 'ID возвращается из слоя инфоблока, но качество результата подтверждается уже в пользовательском сценарии.'),
@@ -256,6 +257,12 @@ const mechanismArticle = {
     heading('После записи — это уже другой разговор'),
     paragraph('Обработчик после добавления удобен для журналирования, запуска поиска или отправки внутреннего уведомления. Но он не должен молча решать судьбу уже созданного элемента. Если побочный шаг упал после того, как Add вернул ID, повторный вызов Add из обработчика легко создаст дубль, а внешний сервис получит два одинаковых запроса. Поэтому после записи я бы сохранял ID, внешний ключ и понятный статус операции, а ошибку реакции разбирал как отдельную задачу.'),
     paragraph('Если синхронизация с внешней системой действительно обязательна для публикации товара, полезно разделить два состояния: «элемент сохранён» и «элемент готов для пользователя». Первый факт подтверждает сервис создания, второй — контрольная проверка после всех зависимых действий. Тогда временный сбой индексации или уведомления не превращается в неясную историю, где никто не понимает, можно ли безопасно повторить импорт.'),
+    heading('Три вопроса до запуска'),
+    orderedList([
+      'Какой инвариант действительно общий для всех способов создания элемента, а какой относится только к форме или импорту?',
+      'Где вызывающий код получит причину отказа: в результате сервиса, в LAST_ERROR или в отдельном журнале операции?',
+      'Как повторный запуск отличит новую запись от уже созданной и не превратит сбой обработчика в дубликат?',
+    ]),
     heading('Как тестировать такую связку'),
     paragraph('В 2018-м легко ограничиться ручной проверкой в админке, но здесь полезно хотя бы зафиксировать короткую матрицу. Она не требует сложного тестового фреймворка: часть сценариев можно выполнить на тестовом инфоблоке и сохранить как чек-лист релиза. Главное — проверять и прямой сервис, и поведение глобального события.'),
     dataTable(
@@ -285,6 +292,7 @@ const fieldArticle = {
     paragraph('Знакомая картина: скрипт вернул ID, в админке новый товар есть, а на сайте его нет. Первый импульс — «почистить кеш». Иногда это действительно помогает, но чаще кеш просто оказывается первым подозреваемым, потому что его легко назвать. Давайте сначала отделим факт записи от публичной видимости и пройдём путь теми же условиями, которыми живёт каталог.'),
     heading('Постановка проблемы'),
     paragraph('Админка и публичный компонент редко показывают одинаковую выборку. Админка может отобразить неактивный элемент, а каталог фильтрует по ACTIVE, датам активности, разделу, правам, цене, наличию и проектным свойствам. Поэтому вопрос «почему элемент не виден?» нельзя решать одной командой. Нужен короткий список слоёв и доказательство на каждом.'),
+    paragraph('Полезно сразу сохранить два разных наблюдения: «запись читается по ID без ограничений» и «запись попадает в публичную выборку». Между ними могут стоять несколько независимых условий. Если журнал хранит только успешный ID, а не фильтр и результат контрольного запроса, следующему разработчику останется лишь гадать, какая граница исключила товар.'),
     figure('/assets/editorial/2018/bitrix-visibility-diagnostic.svg', 'Дерево диагностики: от результата Add к условиям публичного каталога', 'Начинаем не с кеша, а с самого раннего условия, которое может исключить элемент из публичной выборки.'),
     heading('Проверяем по слоям'),
     dataTable(
diff --git a/web/scripts/upgrade-2018-02.mjs b/web/scripts/upgrade-2018-02.mjs
new file mode 100644
index 0000000..d31bdc1
--- /dev/null
+++ b/web/scripts/upgrade-2018-02.mjs
@@ -0,0 +1,498 @@
+function escapeHtml(value) {
+  return String(value)
+    .replaceAll('&', '&')
+    .replaceAll('<', '<')
+    .replaceAll('>', '>')
+    .replaceAll('"', '"')
+    .replaceAll("'", ''');
+}
+
+function paragraph(text) {
+  return '

' + text + '

'; +} + +function heading(text) { + return '

' + text + '

'; +} + +function codeBlock(text) { + return '
' + escapeHtml(text.trim()) + '
'; +} + +function figure(src, alt, caption) { + return '
' + alt + '
' + caption + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function bulletList(items) { + return '
    ' + items.map((item) => '
  • ' + item + '
  • ').join('') + '
'; +} + +function dataTable(headers, rows) { + const head = '
' + headers.map((header) => '').join('') + ''; + const body = '' + rows.map((row) => '' + row.map((cell) => '').join('') + '').join('') + ''; + return '
' + header + '
' + cell + '
' + head + body + '
'; +} + +function sourceList(items) { + return ''; +} + +const phpSetErrorHandler = { + title: 'PHP manual: set_error_handler', + url: 'https://www.php.net/manual/en/function.set-error-handler.php', + note: 'какие ошибки передаются пользовательскому обработчику и какие типы он не перехватывает', +}; + +const phpExceptionHandler = { + title: 'PHP manual: set_exception_handler', + url: 'https://www.php.net/manual/en/function.set-exception-handler.php', + note: 'обработчик непойманного Throwable, который получает Error и Exception', +}; + +const phpShutdown = { + title: 'PHP manual: register_shutdown_function', + url: 'https://www.php.net/manual/en/function.register-shutdown-function.php', + note: 'когда PHP вызывает зарегистрированную функцию завершения', +}; + +const phpLastError = { + title: 'PHP manual: error_get_last', + url: 'https://www.php.net/manual/en/function.error-get-last.php', + note: 'формат последней ошибки: type, message, file и line', +}; + +const phpCurlExec = { + title: 'PHP manual: curl_exec', + url: 'https://www.php.net/manual/en/function.curl-exec.php', + note: 'строгое сравнение с false и отличие ошибки cURL от HTTP-статуса', +}; + +const phpCurlInfo = { + title: 'PHP manual: curl_getinfo', + url: 'https://www.php.net/manual/en/function.curl-getinfo.php', + note: 'данные последней передачи, включая http_code, content_type и total_time', +}; + +const phpCurlErrno = { + title: 'PHP manual: curl_errno', + url: 'https://www.php.net/manual/en/function.curl-errno.php', + note: 'код последней ошибки cURL и ноль при отсутствии ошибки', +}; + +const httpSemantics = { + title: 'RFC 7231, раздел 6: Response Status Codes', + url: 'https://www.rfc-editor.org/rfc/rfc7231#section-6', + note: 'семантика статус-кодов HTTP на уровне протокола', +}; + +const phpJsonDecode = { + title: 'PHP manual: json_decode', + url: 'https://www.php.net/manual/en/function.json-decode.php', + note: 'что возвращает декодер, требование UTF-8 и изменение PHP 7.3', +}; + +const phpJsonLastError = { + title: 'PHP manual: json_last_error', + url: 'https://www.php.net/manual/en/function.json-last-error.php', + note: 'коды ошибок последней операции JSON', +}; + +const jsonRfc = { + title: 'RFC 8259: JSON', + url: 'https://www.rfc-editor.org/rfc/rfc8259.html', + note: 'JSON допускает не только объект и массив, но и null, false, true, число и строку', +}; + +const practiceArticle = { + slug: 'editorial-2018-02-practice-php-diagnostics', + title: 'PHP. Как записать причину 500-й ошибки в интеграции', + categories: ['PHP', 'Отладка', 'Интеграции'], + cover: '/assets/editorial/2018/php-fatal-context-flow.svg', + excerpt: 'Разбираю, как собрать один полезный диагностический факт при фатальной ошибке PHP: где работает set_error_handler, зачем нужен shutdown-обработчик и какие данные нельзя писать в лог.', + readingMinutes: 10, + contentHtml: [ + paragraph('Интеграционный endpoint вернул 500, а в журнале осталась только дата и адрес скрипта. На следующий день партнёр повторяет запрос, но уже с другими данными, и причина исчезает. В такой ситуации не помогает ещё один try/catch вокруг вызова API: часть ошибок PHP до него не дойдёт. Вопрос этой заметки простой: как оставить один диагностический факт с операцией и местом падения, не превращая журнал в копию чужого запроса?'), + heading('Почему одного set_error_handler недостаточно'), + paragraph('Первое, что обычно хочется сделать, — повесить set_error_handler и считать задачу закрытой. У функции есть граница: пользовательский обработчик не получает E_ERROR, E_PARSE, E_CORE_ERROR и E_COMPILE_ERROR. Он также не может увидеть ошибку, случившуюся до регистрации обработчика. Это не дефект функции, а условие, от которого надо строить диагностику.'), + paragraph('Поэтому я разделяю три случая. Обычное предупреждение попадает в обработчик ошибок. Непойманное исключение или Error в PHP 7 попадает в обработчик исключений. Для части фатальных ошибок остаётся функция завершения: PHP вызывает её после окончания скрипта или после exit(), а error_get_last() даёт тип, сообщение, файл и строку последней ошибки. Функция завершения не заменяет нормальную обработку исключений, но закрывает именно этот зазор.'), + figure('/assets/editorial/2018/php-fatal-context-flow.svg', 'Схема: контекст операции создаётся перед интеграцией; предупреждение идёт в set_error_handler, исключение — в set_exception_handler, фатальная ошибка проверяется при shutdown', 'Один request ID проходит через все три ветки. В журнале видно не только текст PHP, но и операцию, на которой он возник.'), + heading('Сначала определить, что именно нужно найти потом'), + paragraph('Лог полезен, если по одной записи можно ответить на четыре вопроса: какая операция шла, какой внешний идентификатор обрабатывался, где остановился код и какой класс ошибки случился. Записывать целиком $_POST, заголовок авторизации или ответ партнёра для этого не нужно. В них часто лежат пароли, персональные данные и токены; при расследовании такой журнал создаёт вторую проблему.'), + paragraph('Для импорта заказа я оставляю короткий контекст: случайный ID операции, имя интеграции, внешний ID заказа и этап. Этап меняется перед опасным участком: request_prepared, partner_called, response_saved. Если процесс оборвался, последняя метка намного полезнее догадки по номеру строки.'), + dataTable( + ['Поле журнала', 'Пример', 'Зачем оно нужно'], + [ + ['request_id', 'sync-20180207-4f2a', 'Связать запись PHP с логом веб-сервера и сообщением партнёра'], + ['operation', 'order_export', 'Не смешать импорт каталога, webhook и ручной запуск'], + ['external_id', 'ORD-9182', 'Повторить один сценарий без поиска по всему набору данных'], + ['stage', 'partner_called', 'Понять, успел ли код дойти до внешнего вызова'], + ['error_type', 'E_ERROR или Throwable', 'Отделить ошибку PHP от ответа HTTP'], + ['file, line', 'путь и строка', 'Открыть точку падения в той версии кода, которая работала в момент сбоя'], + ], + ), + heading('Минимальная обвязка для PHP 7'), + paragraph('Ниже пример для одного HTTP-запроса. Он не пытается перехватить всё подряд и не меняет поведение штатного обработчика PHP: после записи предупреждения возвращается false. Это удобно на первом внедрении: существующие настройки error_reporting и журнал сервера остаются на месте, а рядом появляется структурированная запись для интеграции.'), + codeBlock(String.raw` + $requestId, + 'operation' => $operation, + 'external_id' => $externalId, + 'stage' => 'started', + ); + + $setStage = function ($stage) use (&$context) { + $context['stage'] = $stage; + }; + + set_error_handler(function ($severity, $message, $file, $line) use (&$context) { + if (!(error_reporting() & $severity)) { + return false; + } + + writeIntegrationLog($context + array( + 'kind' => 'php_error', + 'error_type' => $severity, + 'message' => $message, + 'file' => $file, + 'line' => $line, + )); + + return false; + }); + + set_exception_handler(function (Throwable $error) use (&$context) { + writeIntegrationLog($context + array( + 'kind' => 'uncaught_throwable', + 'class' => get_class($error), + 'message' => $error->getMessage(), + 'file' => $error->getFile(), + 'line' => $error->getLine(), + )); + }); + + register_shutdown_function(function () use (&$context) { + $last = error_get_last(); + $fatalTypes = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR); + + if ($last === null || !in_array($last['type'], $fatalTypes, true)) { + return; + } + + writeIntegrationLog($context + array( + 'kind' => 'fatal_error', + 'error_type' => $last['type'], + 'message' => $last['message'], + 'file' => $last['file'], + 'line' => $last['line'], + )); + }); + + return $setStage; +} + +$setStage = installIntegrationDiagnostics( + 'sync-20180207-4f2a', + 'order_export', + 'ORD-9182' +); + +$setStage('request_prepared'); +// Здесь вызывается клиент партнёра. +$setStage('partner_called'); +`), + paragraph('В настоящем коде генерация request_id и запись журнала обычно живут в приложении, а не в каждой интеграции. Здесь они оставлены рядом, чтобы видно было главное: контекст создаётся до внешнего вызова, а не в блоке обработки ошибки. Нельзя восстановить по фатальной ошибке то, что код не успел записать.'), + heading('Как проверить схему до аварии'), + paragraph('Проверять такую обвязку лучше не на боевом заказе. Для предупреждения достаточно отдельного скрипта с trigger_error("diagnostic test", E_USER_WARNING). Для исключения — выбросить RuntimeException после установки этапа. Фатальный путь нужно запускать только в изолированной среде: ошибка, которую нельзя перехватить через set_error_handler, должна оставить запись из shutdown-функции, а сам тест не должен менять состояние сторонней системы.'), + orderedList([ + 'Добавить обвязку в точку входа до вызова клиента интеграции и задать request_id, операцию и внешний ID.', + 'Запустить локальный сценарий с предупреждением и убедиться, что в журнале есть все поля таблицы, а штатное сообщение PHP не исчезло.', + 'Запустить сценарий с непойманным исключением в отдельном endpoint и проверить запись с классом исключения и последним этапом.', + 'В тестовой среде проверить фатальный случай после регистрации обработчиков и убедиться, что shutdown-запись не дублирует обычные предупреждения.', + 'Открыть журнал с позиции человека, который не видел код: по одной строке должно быть понятно, какой внешний объект повторять и где смотреть дальше.', + ]), + heading('Где эта схема заканчивается'), + paragraph('Она не ловит синтаксическую ошибку в файле, который не дал приложению стартовать: обработчики ещё не зарегистрированы. Она не гарантирует запись при принудительном завершении процесса. Она не заменяет мониторинг 500-х на уровне веб-сервера. И она не даёт права сохранять секреты в журнал. Для таких случаев остаются деплой-проверки, журналы окружения и правила маскирования данных.'), + paragraph('Ещё одна граница — дубли. Ошибка внутри set_error_handler и ошибка в shutdown-функции не должны сами вызвать бесконечный поток записей. Поэтому запись должна быть короткой, а логгер — максимально простым. Если для доставки лога нужен сетевой запрос, я бы не ставил его в shutdown-путь: при падении сети потеряем и исходную ошибку, и время на разбор.'), + heading('Порядок, который остаётся в проекте'), + paragraph('Сначала ставим контекст, затем меняем этапы перед побочными эффектами, потом отдельно видим предупреждение, исключение и фатальный случай. После этого ошибка 500 перестаёт быть сообщением «что-то не так». В ней есть операция, внешний объект, последняя пройденная граница и место в коде. Этого достаточно, чтобы воспроизвести проблему до следующего запроса партнёра.'), + heading('Проверяемые источники'), + sourceList([phpSetErrorHandler, phpExceptionHandler, phpShutdown, phpLastError]), + ].join('\n'), +}; + +const mechanismArticle = { + slug: 'editorial-2018-02-mechanism-php-diagnostics', + title: 'PHP и cURL. Почему curl_exec() не означает успех интеграции', + categories: ['PHP', 'cURL', 'Интеграции'], + cover: '/assets/editorial/2018/curl-outcome-classifier.svg', + excerpt: 'curl_exec() может вернуть тело ответа, хотя партнёр ответил 404 или 500. Разбираю три уровня результата: транспорт, HTTP и контракт полезной нагрузки.', + readingMinutes: 10, + contentHtml: [ + paragraph('После ночной выгрузки в логе стоит «запрос выполнен», потому что curl_exec() вернул строку. Утром выясняется, что строкой была HTML-страница с 403, а заказы не дошли. Ошибка в проверке не синтаксическая: код спросил cURL только о доставке ответа, а бизнес-код сделал вывод о результате всей операции. Разберём один вопрос: какой минимальный набор проверок отличает сетевой сбой, HTTP-отказ и рабочий ответ партнёра?'), + heading('У одного вызова три разных результата'), + paragraph('При включённом CURLOPT_RETURNTRANSFER функция curl_exec() возвращает тело ответа при успехе cURL и false при его ошибке. Проверять результат надо строгим сравнением: непустое тело может быть строкой "0", которая в обычном условии ведёт себя как ложь. Главное здесь другое: статус 404 или 500 сам по себе не считается ошибкой cURL. Документация прямо предлагает читать HTTP-статус через curl_getinfo().'), + paragraph('Отсюда порядок проверки. Сначала узнаём, состоялась ли передача: $body === false, curl_errno() и curl_error(). Затем читаем http_code, тип содержимого и время из curl_getinfo(). Только после этого разбираем тело как JSON или иной формат, который обещан договором с партнёром. Если смешать уровни, журнал начинает сообщать «ошибка API» и для DNS, и для 401, и для сломанного JSON.'), + figure('/assets/editorial/2018/curl-outcome-classifier.svg', 'Диаграмма классификации ответа cURL: false ведёт к транспортной ошибке; строка проверяется по HTTP-коду, затем по контракту тела', 'Положительный результат cURL означает, что библиотека получила ответ. Он ещё не означает, что HTTP-запрос и бизнес-операция завершились успешно.'), + heading('Что сохранять для каждого уровня'), + dataTable( + ['Наблюдение', 'Класс сбоя', 'Что записать в журнал', 'Следующее действие'], + [ + ['$body === false', 'Транспорт или TLS', 'curl_errno, curl_error, URL без секрета, время', 'Проверить DNS, сертификат, таймаут и доступность хоста'], + ['Есть тело, http_code 401 или 403', 'Авторизация или права', 'HTTP-код, операция, внешний ID, request ID', 'Проверить учётные данные и область доступа; не печатать токен'], + ['Есть тело, http_code 404', 'Адрес или версия API', 'HTTP-код и маршрут без query-параметров', 'Сверить путь, метод и версию endpoint'], + ['Есть тело, http_code 500', 'Ошибка удалённой стороны', 'HTTP-код, request ID, первые безопасные признаки ответа', 'Передать партнёру ID запроса и время, не повторять запись вслепую'], + ['2xx и ожидаемое тело', 'Транспорт и HTTP прошли', 'Код, размер и время ответа', 'Проверить обязательные поля тела перед изменением локальных данных'], + ], + ), + heading('Клиент, который не прячет уровень ошибки'), + paragraph('В примере ниже нет общего «интеграционного клиента». Нужна маленькая функция, которую легко вызвать в изолированном скрипте и легко покрыть разными ответами. Время соединения и общий таймаут здесь проектные: их надо выбирать под договорённость с конкретным сервисом, а не переносить числа из чужого кода.'), + codeBlock(String.raw` + true, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => 10, + CURLOPT_HTTPHEADER => array( + 'Accept: application/json', + 'X-Request-Id: ' . $requestId, + ), + )); + + $body = curl_exec($handle); + $curlErrno = curl_errno($handle); + $curlError = curl_error($handle); + $info = curl_getinfo($handle); + curl_close($handle); + + if ($body === false) { + throw new RuntimeException(json_encode(array( + 'kind' => 'transport_error', + 'request_id' => $requestId, + 'curl_errno' => $curlErrno, + 'curl_error' => $curlError, + 'total_time' => $info['total_time'], + ))); + } + + $status = (int) $info['http_code']; + if ($status < 200 || $status >= 300) { + throw new RuntimeException(json_encode(array( + 'kind' => 'http_error', + 'request_id' => $requestId, + 'http_code' => $status, + 'content_type' => $info['content_type'], + 'body_bytes' => strlen($body), + 'total_time' => $info['total_time'], + ))); + } + + return array( + 'body' => $body, + 'content_type' => $info['content_type'], + 'http_code' => $status, + 'total_time' => $info['total_time'], + ); +} +`), + paragraph('Функция намеренно не пишет URL целиком. Query-параметры часто содержат ключи, подписи или персональные идентификаторы. Если адрес нужен для расследования, лучше сохранить имя интеграции и заранее нормализованный путь. Тело ответа тоже не стоит бездумно добавлять к исключению: для первичного поиска достаточно размера, типа содержимого и ID операции; безопасный фрагмент можно сохранить отдельно в тестовой среде.'), + heading('Почему 2xx — ещё не результат операции'), + paragraph('HTTP-код описывает ответ сервера на протокольном уровне. Он не может за нас подтвердить, что партнёр принял заказ в нужном виде. Один API возвращает {"id":"A-17"}, другой — {"accepted":true}, третий ставит задачу в очередь. Поэтому после 2xx должна быть проверка конкретного поля, которое выбрано в контракте. Разбор JSON и формы ответа — отдельная граница; её нельзя заменять условием if ($body).'), + paragraph('Это же объясняет, почему автоматический повтор записи нельзя включать как реакцию на любой сбой. При таймауте неизвестно, дошёл ли запрос до партнёра. Если операция создаёт заказ, второй POST может создать дубликат. Повтор становится безопасным только когда протокол даёт ключ идемпотентности, внешний ID или отдельный способ узнать результат первой попытки. Пока такого условия нет, в журнале должна появиться операция для разбора, а не второй запрос в фоне.'), + heading('Воспроизводимая матрица проверки'), + paragraph('Для проверки не нужен настоящий партнёр. Достаточно небольшого тестового endpoint, который по параметру возвращает 200 с JSON, 401, 500 и закрывает соединение. Важно сравнивать не только текст исключения, но и поля записи: у каждого сценария должен быть свой kind. Тогда мониторинг может отдельно считать транспортные сбои и ответы 5xx.'), + orderedList([ + 'Включить CURLOPT_RETURNTRANSFER и заменить все проверки if (!$body) на строгое $body === false.', + 'Сразу после curl_exec() собрать curl_errno, curl_error и curl_getinfo, пока handle не закрыт.', + 'Прогнать endpoint с недоступным адресом и проверить ветку transport_error с ненулевым кодом cURL.', + 'Прогнать 401, 404 и 500; у них должна сработать ветка http_error, а не транспортная ошибка.', + 'Прогнать 200 с корректным, но неожиданным телом и убедиться, что следующий слой контракта его не принимает автоматически.', + ]), + heading('Границы примера'), + paragraph('Пример не задаёт универсальные таймауты и не обещает повтор запросов. Он не проверяет сертификаты вручную и не отключает TLS-проверку: если есть ошибка сертификата, её следует увидеть как транспортную причину и исправить настройку окружения. Он также не заменяет лимиты на размер ответа и контроль метода HTTP — эти правила зависят от конкретного API.'), + paragraph('Но даже такая небольшая развилка меняет качество диагностики. Вместо одного сообщения «интеграция не работает» появляются три проверяемых факта: передача не состоялась, удалённый сервер ответил не тем статусом или статус нормальный, но тело не прошло контракт. Дальше можно обсуждать решение с человеком, который отвечает именно за этот слой.'), + heading('Проверяемые источники'), + sourceList([phpCurlExec, phpCurlInfo, phpCurlErrno, httpSemantics]), + ].join('\n'), +}; + +const fieldArticle = { + slug: 'editorial-2018-02-field-php-diagnostics', + title: 'PHP. Как отличить битый JSON от корректного null в ответе API', + categories: ['PHP', 'JSON', 'Интеграции'], + cover: '/assets/editorial/2018/json-payload-diagnostic.svg', + excerpt: 'Проверка if (!$data) смешивает пустой массив, false, null и ошибку декодирования. Собираем короткий разбор JSON для PHP 7.1 с проверкой json_last_error и контракта ответа.', + readingMinutes: 9, + contentHtml: [ + paragraph('В обработчике ответа часто встречается одна строка: if (!$data) { throw new Exception("bad response"); }. После неё невозможно понять, что случилось: партнёр вернул пустой список, честное null, число 0 или HTML вместо JSON. Ниже я оставляю пример в рамках PHP 7.1: в этой версии ещё нет JSON_THROW_ON_ERROR, поэтому после json_decode() нужно явно проверить состояние декодера.'), + paragraph('Главный вопрос здесь узкий: как отделить ошибку разбора JSON от корректного JSON, который не соответствует нашему договору? Ответ состоит из двух проверок подряд. Сначала сразу читаем json_last_error(). Только если там JSON_ERROR_NONE, проверяем тип и обязательные поля ответа.'), + heading('Почему null не доказывает ошибку'), + paragraph('По RFC 8259 JSON-текстом может быть не только объект или массив: допустимы также строка, число, false, true и null. PHP отражает это напрямую: json_decode("null") возвращает null, но null возвращается и когда строку нельзя декодировать. Одна проверка на значение не различает эти случаи.'), + paragraph('То же происходит с пустыми коллекциями. После json_decode("[]", true) получится пустой массив, который в PHP является ложным в условии. Это может быть правильный ответ поиска: товаров нет. Но тот же if (!$data) назовёт его «битым JSON». Сначала нужно проверить синтаксис, затем форму данных, и только потом решать, допустим ли пустой результат для данной операции.'), + figure('/assets/editorial/2018/json-payload-diagnostic.svg', 'Схема диагностики JSON: сырой ответ сначала проходит json_decode и json_last_error, затем проверку типа и обязательных полей контракта', 'Ошибка декодирования и нарушение контракта — разные события. У них разные владельцы и разные действия.'), + heading('Короткая таблица, которую стоит держать рядом с кодом'), + dataTable( + ['Сырой ответ', 'Результат json_decode(..., true)', 'json_last_error', 'Что это значит для клиента'], + [ + ['{"order_id":"A-17"}', 'ассоциативный массив', 'JSON_ERROR_NONE', 'Проверить поле order_id и принять ответ'], + ['[]', 'пустой массив', 'JSON_ERROR_NONE', 'Корректный JSON; допустимость зависит от операции'], + ['null', 'null', 'JSON_ERROR_NONE', 'Корректный JSON, но не тот тип, который ждёт данный endpoint'], + ['false или 0', 'false или 0', 'JSON_ERROR_NONE', 'Корректный JSON; проверка на «ложь» здесь ошибочна'], + ['<html>503</html>', 'обычно null', 'JSON_ERROR_SYNTAX', 'Неверный формат ответа; сохранить безопасный диагностический контекст'], + ], + ), + heading('Пример для PHP 7.1'), + paragraph('Функция ниже принимает уже полученное тело только после проверки HTTP-статуса. Она не пытается угадать, где возникли данные: транспорт и HTTP должны быть разобраны раньше. Здесь контракт намеренно маленький: мы ждём объект с непустым строковым order_id. В другом API это может быть список, поле accepted или код задачи — меняется проверка контракта, но не порядок диагностики.'), + codeBlock(String.raw` + 'contract_error', + 'reason' => $reason, + 'request_id' => $requestId, + 'body_bytes' => strlen($body), + 'body_sha256' => hash('sha256', $body), + )); + + throw new UnexpectedValueException($reason); +} + +function decodeCreatedOrder($body, $requestId) +{ + if ($body === '') { + rejectPayloadContract('Partner returned an empty body', $requestId, $body); + } + + $data = json_decode($body, true); + $jsonError = json_last_error(); + + if ($jsonError !== JSON_ERROR_NONE) { + logPayloadProblem(array( + 'kind' => 'json_decode_error', + 'request_id' => $requestId, + 'json_error' => $jsonError, + 'body_bytes' => strlen($body), + 'body_sha256' => hash('sha256', $body), + )); + + throw new UnexpectedValueException('Partner response is not valid JSON'); + } + + if (!is_array($data)) { + rejectPayloadContract( + 'Partner returned valid JSON, but not an object', + $requestId, + $body + ); + } + + if ( + !array_key_exists('order_id', $data) + || !is_string($data['order_id']) + || $data['order_id'] === '' + ) { + rejectPayloadContract( + 'Partner JSON has no non-empty order_id', + $requestId, + $body + ); + } + + return $data; +} +`), + paragraph('Значение json_last_error() читается сразу после json_decode(). Это состояние относится к последней операции JSON, поэтому его легко затереть следующим json_encode() или повторным разбором. В примере в журнал попадают код ошибки, размер тела и хеш. Хеш позволяет сравнить два ответа, не печатая сам ответ в общий журнал. Если отладка требует фрагмент тела, её лучше проводить в ограниченной тестовой среде с маскированием данных.'), + heading('Не путать формат с договором'), + paragraph('Предположим, партнёр ответил []. С точки зрения JSON всё в порядке. Для запроса «найди заказы за час» это может быть нормальный нулевой результат. Для запроса «создай заказ» пустой массив не годится, потому что договор ожидает идентификатор. Это уже не ошибка декодера и не повод говорить, что «API вернул битый JSON». Это нарушение контракта полезной нагрузки.'), + paragraph('Такая формулировка помогает и при разговоре с партнёром. Вместо расплывчатого «не распарсили ответ» можно передать факт: HTTP-статус был 200, JSON синтаксически корректен, но поле order_id отсутствует или имеет другой тип. Это сообщение можно проверить на их стороне и закрепить в документации API.'), + heading('Проверка на четырёх маленьких ответах'), + paragraph('Тест не обязан ходить в сеть. Достаточно передать функции строки и сравнить исключение или результат. Важно держать рядом успешный пустой сценарий только для той операции, где пустота допустима: иначе тест сам начнёт размывать договор.'), + orderedList([ + 'Передать {"order_id":"A-17"} и проверить, что функция вернула массив с идентификатором.', + 'Передать <html>maintenance</html>; ожидается ветка json_decode_error с кодом JSON_ERROR_SYNTAX.', + 'Передать null; json_last_error() должен показать успех разбора, а функция должна отклонить неподходящий тип.', + 'Передать []; разбор успешен, но контракт создания заказа должен отклонить отсутствие order_id.', + 'Отдельно проверить поиск или список, где [] является валидным результатом, чтобы не переносить правила одной операции на другую.', + ]), + heading('Версия PHP и ограничения'), + paragraph('В PHP 7.3 появился флаг JSON_THROW_ON_ERROR. В этом примере я намеренно остаюсь на PHP 7.1, поэтому проверка json_last_error() — нормальный механизм для выбранной версии, а не обходной путь. Если проект уже обновлён, исключения могут сделать код компактнее, но проверка формы ответа всё равно остаётся.'), + paragraph('Декодер ожидает строку в UTF-8. Ошибка JSON_ERROR_UTF8 говорит о проблеме кодировки, но не объясняет, на каком именно участке она появилась. Для такого случая нужны метрики и безопасный способ сравнить исходные ответы, а не принудительное перекодирование всей строки без понимания источника. RFC 8259 рекомендует уникальные имена в объекте; при повторе разные реализации могут вести себя по-разному. Если поле критично, договор API должен фиксировать его единственность.'), + heading('Что оставить после исправления'), + paragraph('После этой доработки в клиенте остаются два разных события: json_decode_error для невалидного формата и contract_error для валидного, но неожиданного объекта. У них разные причины, разная срочность и разные адресаты. А условие if (!$data) исчезает: оно не способно сказать, что именно произошло.'), + heading('Проверяемые источники'), + sourceList([phpJsonDecode, phpJsonLastError, jsonRfc]), + ].join('\n'), +}; + +const revisions = [practiceArticle, mechanismArticle, fieldArticle]; + +function plainText(content) { + return content + .replace(/

Проверяемые источники<\/h2>[\s\S]*$/, '') + .replace(/<[^>]+>/g, ' ') + .replace(/&(?:quot|amp|lt|gt|#039);/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function assertRevisionQuality(revision) { + const body = plainText(revision.contentHtml); + const issues = []; + + if (body.length < 5000 || body.length > 15000) { + issues.push('основной текст: ' + body.length + ' знаков'); + } + if ((revision.contentHtml.match(/
/g) || []).length !== 1) { + issues.push('нужен ровно один главный рисунок'); + } + if (!revision.contentHtml.includes('')) issues.push('нет таблицы'); + if (!revision.contentHtml.includes('
')) issues.push('нет примера кода');
+  if (!revision.contentHtml.includes('
    ')) issues.push('нет последовательности действий'); + if (!revision.contentHtml.includes('

    Проверяемые источники

    ')) { + issues.push('нет раздела с источниками'); + } + if ((revision.contentHtml.match(/ '

    ' + content + '

    '; +const heading = (content) => '

    ' + content + '

    '; +const codeBlock = (source) => '
    ' + escapeHtml(source.trim()) + '
    '; +const figure = (src, alt, caption) => [ + '
    ', + '' + alt + '', + '
    ' + caption + '
    ', + '
    ', +].join(''); + +function dataTable(headers, rows) { + const head = headers.map((header) => '
').join(''); + const body = rows.map((row) => ( + '' + row.map((cell) => '').join('') + '' + )).join(''); + + return '
' + header + '
' + cell + '
' + head + + '' + body + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function bulletList(items) { + return '
    ' + items.map((item) => '
  • ' + item + '
  • ').join('') + '
'; +} + +function sourceList(sources) { + return '
'; +} + +const phpUploadErrors = { + title: 'PHP Manual: коды ошибок загрузки', + url: 'https://www.php.net/manual/en/features.file-upload.errors.php', +}; +const phpMoveUploadedFile = { + title: 'PHP Manual: move_uploaded_file', + url: 'https://www.php.net/manual/en/function.move-uploaded-file.php', +}; +const phpFileinfo = { + title: 'PHP Manual: finfo_file', + url: 'https://www.php.net/manual/en/function.finfo-file.php', +}; +const phpGetImageSize = { + title: 'PHP Manual: getimagesize и его ограничение как валидатора', + url: 'https://www.php.net/manual/en/function.getimagesize.php', +}; +const phpHeader = { + title: 'PHP Manual: header', + url: 'https://www.php.net/manual/en/function.header.php', +}; +const phpReadfile = { + title: 'PHP Manual: readfile', + url: 'https://www.php.net/manual/en/function.readfile.php', +}; +const multipartRfc = { + title: 'RFC 7578: multipart/form-data', + url: 'https://www.rfc-editor.org/rfc/rfc7578', +}; +const contentDispositionRfc = { + title: 'RFC 6266: Content-Disposition в HTTP', + url: 'https://www.rfc-editor.org/rfc/rfc6266', +}; +const owaspUpload = { + title: 'OWASP File Upload Cheat Sheet', + url: 'https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html', +}; + +const practiceArticle = { + slug: 'editorial-2018-03-practice-safe-uploads', + title: 'PHP. Безопасная загрузка аватара: минимальный маршрут без доверия к имени файла', + categories: ['PHP', 'Безопасность'], + cover: '/assets/editorial/2018/php-upload-avatar-contract.svg', + excerpt: 'Собираем маленький обработчик для JPEG и PNG: проверяем доставку, размер и содержимое, сохраняем под своим именем и не отдаём путь из веб-корня.', + readingMinutes: 9, + contentHtml: [ + paragraph('Загрузка аватара обычно начинается с одного поля формы и вызова move_uploaded_file. Ошибка становится заметна позже: каталог uploads оказывается доступен из веб-корня, имя файла совпадает с уже существующим, а проверка сводится к .jpg. В итоге сервер принимает решение по данным, которые прислал браузер. Давайте соберём минимальный маршрут, где каждое такое решение видно в коде.'), + paragraph('Вопрос этой заметки один: как принять только JPEG и PNG для аватара, не превращая имя и MIME-тип из формы в правило безопасности? Пример рассчитан на PHP 7.2. Он не заменяет антивирус и не умеет обрабатывать документы; его задача уже — дать узкий и проверяемый вход для изображения.'), + heading('Сначала договоримся о результате'), + paragraph('Форма передаёт один файл avatar. Мы принимаем не более 2 МБ, только image/jpeg и image/png, а затем ограничиваем ширину и высоту. В базе или профиле хранится ключ, который придумало приложение, например 7f4a...c2.png. Исходное имя можно показать пользователю после отдельной обработки, но оно не участвует в пути на диске.'), + figure( + '/assets/editorial/2018/php-upload-avatar-contract.svg', + 'Путь файла аватара: браузер передаёт multipart-часть, PHP создаёт временный файл, код проверяет его и переносит в закрытое хранилище под сгенерированным ключом.', + 'Проверки идут до переноса. После переноса остаётся ключ приложения, а не имя из формы.', + ), + dataTable( + ['Проверка', 'Что она отвечает', 'Что делаем при отказе'], + [ + ['UPLOAD_ERR_OK', 'PHP полностью принял часть запроса', 'Не читаем временный путь, показываем понятную ошибку загрузки'], + ['Лимит 2 МБ', 'Файл укладывается в договор аватара', 'Не переносим файл и не пытаемся уменьшать его вслепую'], + ['finfo_file', 'Какой MIME-тип определён по временному файлу', 'Отклоняем тип, которого нет в белом списке'], + ['Размеры изображения', 'Подходит ли картинка для интерфейса', 'Отклоняем слишком маленькое или слишком большое изображение'], + ['Сгенерированный ключ', 'Куда именно будет записан файл', 'Никогда не составляем путь из исходного имени'], + ], + ), + heading('Обработчик без скрытого шага'), + paragraph('Проверка $_FILES["avatar"]["error"] должна идти первой. PHP кладёт в это поле код доставки: если загрузка не завершилась, временный файл нельзя считать нормальным входом. Затем я сравниваю размер и запускаю Fileinfo для временного файла. Поле type из $_FILES здесь намеренно не используется: его прислал клиент.'), + codeBlock(String.raw` + $maxBytes) { + throw new RuntimeException('Аватар больше 2 МБ'); + } + + $finfo = finfo_open(FILEINFO_MIME_TYPE); + if ($finfo === false) { + throw new RuntimeException('Расширение Fileinfo недоступно'); + } + + $mime = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + + $allowed = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + ]; + + if (!is_string($mime) || !isset($allowed[$mime])) { + throw new RuntimeException('Нужен JPEG или PNG'); + } + + $size = getimagesize($file['tmp_name']); + if ($size === false) { + throw new RuntimeException('Не удалось прочитать размеры изображения'); + } + + list($width, $height) = $size; + if ($width < 64 || $height < 64 || $width > 3000 || $height > 3000) { + throw new RuntimeException('Размеры изображения вне допустимого диапазона'); + } + + $storageKey = bin2hex(random_bytes(16)) . '.' . $allowed[$mime]; + $target = rtrim($privateDir, DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR . $storageKey; + + if (!move_uploaded_file($file['tmp_name'], $target)) { + throw new RuntimeException('Не удалось сохранить аватар'); + } + + return [ + 'storageKey' => $storageKey, + 'mime' => $mime, + 'width' => $width, + 'height' => $height, + ]; +} +`), + heading('Почему порядок проверок важнее набора функций'), + paragraph('У move_uploaded_file есть собственная проверка: исходный путь должен быть файлом, пришедшим через HTTP POST. Это полезная граница, но она не говорит, что перед нами именно изображение для аватара. Поэтому перенос стоит последним. До него мы принимаем решение по коду ошибки, размеру, серверному определению MIME-типа и проектным размерам.'), + paragraph('Вызов getimagesize нужен здесь только для размеров. В документации PHP отдельно сказано не использовать его как проверку того, что файл является корректным изображением; для определения типа подходит Fileinfo. Это хороший пример узкой ответственности: одна функция отвечает за признаки файла, другая — за параметры картинки, а не за всё сразу.'), + heading('Минимальная форма и проверка руками'), + codeBlock(String.raw` +
+ + +
+`), + paragraph('Атрибут accept помогает интерфейсу, но не заменяет серверную проверку. После подключения обработчика я бы не ограничивался одним удачным JPEG. Нужны четыре коротких сценария: нормальный JPEG, PNG, текстовый файл с расширением .jpg и картинка больше лимита. Для каждого фиксируем HTTP-ответ, наличие или отсутствие файла в хранилище и запись ключа в профиле.'), + heading('Порядок запуска'), + orderedList([ + 'Создать отдельный каталог для файлов за пределами веб-корня и дать PHP права только на нужную операцию записи.', + 'Подключить форму с multipart/form-data и передать $_FILES["avatar"] в функцию.', + 'После успешного вызова сохранить только storageKey, MIME-тип и размеры рядом с пользователем.', + 'Проверить отрицательные сценарии: при любой ошибке ни файл, ни ссылка на него не должны появиться в профиле.', + 'Отдельно решить, как читать аватар пользователю: прямой URL подходит лишь для действительно публичной картинки.', + ]), + heading('Граница этого примера'), + paragraph('Код не сканирует файл на вредоносное содержимое и не защищает форму от CSRF. Он также не делает миниатюры: если добавить внешний конвертер, появится отдельная граница с лимитами, тайм-аутами и обновлением библиотек. Для аватаров я бы сначала запустил ровно этот узкий маршрут, измерил ошибки и только потом усложнял обработку.'), + heading('Проверяемые источники'), + sourceList([phpUploadErrors, phpMoveUploadedFile, phpFileinfo, phpGetImageSize, owaspUpload]), + ].join('\n'), +}; + +const mechanismArticle = { + slug: 'editorial-2018-03-mechanism-safe-uploads', + title: 'PHP. Почему расширение и Content-Type не отвечают на вопрос «что за файл?»', + categories: ['PHP', 'Безопасность'], + cover: '/assets/editorial/2018/php-upload-trust-signals.svg', + excerpt: 'Разбираем, какие сведения о загрузке пришли от клиента, какие получил PHP и где серверу действительно стоит принимать решение о допустимом файле.', + readingMinutes: 9, + contentHtml: [ + paragraph('Симптом: обработчик пропускает файл с type=image/jpeg, хотя Fileinfo для временного файла определяет другой тип. Цена ошибки — приложение сохраняет и позднее выдаёт контент, которого этот маршрут не должен был принимать. Самая коварная строка в обработчике загрузки выглядит безобидно: if ($file["type"] === "image/jpeg"). Она работает с обычным браузером и ломает модель в тот момент, когда запрос собран не браузером. В multipart-форме имя файла и Content-Type — часть сообщения клиента. Сервер получает эти поля, но не обязан считать их доказательством содержимого.'), + paragraph('Главный вопрос статьи: какие признаки файла можно использовать для какой проверки? Ответ не сводится к одной «правильной» функции. У доставки, типа, размеров и имени разные источники, поэтому их нельзя склеивать в одну проверку с красивым названием validateUpload().'), + heading('Где заканчиваются сведения клиента'), + paragraph('RFC 7578 описывает multipart/form-data: файл приходит отдельной частью с заголовками, среди которых может быть Content-Type. Это формат передачи, а не подпись под содержимым. PHP раскладывает результат в $_FILES; там есть исходное имя, клиентский тип, размер, временный путь и код ошибки. У каждого поля своя ценность.'), + figure( + '/assets/editorial/2018/php-upload-trust-signals.svg', + 'Схема границ доверия: имя и Content-Type идут от клиента, PHP сообщает результат доставки, Fileinfo изучает временный файл, а приложение применяет собственный белый список.', + 'Клиентские метаданные полезны для интерфейса и диагностики. Решение о допуске принимает приложение после проверки временного файла.', + ), + dataTable( + ['Сигнал', 'Откуда он взялся', 'Правильное применение'], + [ + ['$file["name"]', 'Имя, переданное клиентом', 'Показать как подпись после экранирования; не строить из него путь'], + ['Расширение', 'Часть клиентского имени', 'Использовать как удобный фильтр интерфейса, но не как доказательство типа'], + ['$file["type"]', 'Content-Type multipart-части', 'Сохранить в отладочном журнале, но не использовать для допуска'], + ['$file["error"]', 'Результат, который сообщил PHP', 'Продолжать только при UPLOAD_ERR_OK'], + ['finfo_file()', 'Анализ временного файла на сервере', 'Сравнить с точным белым списком допустимых MIME-типов'], + ['getimagesize()', 'Попытка прочитать параметры изображения', 'Проверить размеры после Fileinfo, но не считать это проверкой безопасности'], + ], + ), + heading('Короткий опыт на локальной машине'), + paragraph('Ниже не нужен вредоносный файл. Достаточно обычного текста и вручную заданного Content-Type. Поднимите встроенный сервер PHP в каталоге с inspect.php, отправьте файл через curl и посмотрите на два значения. Конкретный MIME-результат Fileinfo может зависеть от его базы, но он определяется по временному файлу, а не по параметру type=image/jpeg в команде.'), + codeBlock(String.raw` +это не фотография' > /tmp/not-an-image.txt +php -S 127.0.0.1:8080 + +curl -F 'avatar=@/tmp/not-an-image.txt;type=image/jpeg' \ + http://127.0.0.1:8080/inspect.php +`), + paragraph('Такой опыт не доказывает, что Fileinfo распознает все форматы без ошибок. Он доказывает более скромную вещь: строка $file["type"] описывает заявление отправителя, а не результат серверной проверки. Этого уже достаточно, чтобы убрать её из условия допуска.'), + heading('Функция, которая возвращает только полезный контракт'), + paragraph('После опыта можно свести проверку к небольшому контракту. Функция ниже не переносит файл и не создаёт запись в базе. Она отвечает только на вопрос, можно ли передать временный файл следующему шагу, и возвращает значение, которое тот шаг действительно использует.'), + codeBlock(String.raw` + 2097152) { + throw new RuntimeException('Размер файла недопустим'); + } + + $finfo = finfo_open(FILEINFO_MIME_TYPE); + if ($finfo === false) { + throw new RuntimeException('Fileinfo недоступен'); + } + + $mime = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + + $extensions = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + ]; + + if (!is_string($mime) || !isset($extensions[$mime])) { + throw new RuntimeException('Допустимы только JPEG и PNG'); + } + + return [ + 'temporaryPath' => $file['tmp_name'], + 'mime' => $mime, + 'extension' => $extensions[$mime], + 'bytes' => (int)$file['size'], + ]; +} +`), + heading('Почему это не «одна проверка вместо всех»'), + paragraph('Fileinfo отвечает на вопрос о типе, но не о праве пользователя загружать файл, не о свободном месте и не о том, можно ли безопасно разбирать этот формат дополнительной библиотекой. В нашем случае разрешены только две картинки, поэтому белый список короткий. Если продукту нужны PDF, архивы и таблицы, лучше не расширять тот же массив до десятка значений, а сделать отдельные маршруты с отдельными лимитами и правилами выдачи.'), + paragraph('Расширение всё ещё может быть полезным для интерфейса: по нему браузер открывает фильтр выбора, а пользователь понимает, какой файл выбрал. Но серверный ключ и расширение результата лучше строить из решения приложения: Fileinfo вернул image/png — приложение выбирает .png. Так имя не способно незаметно поменять путь или ожидаемый обработчик.'), + heading('Последовательность проверки'), + orderedList([ + 'Проверить код UPLOAD_ERR_* и остановиться до чтения временного файла при любой ошибке.', + 'Проверить размер, потому что допустимый тип не отменяет ограничение на место и время обработки.', + 'Определить MIME-тип через Fileinfo и сравнить его с белым списком именно этого сценария.', + 'Если нужны размеры, прочитать их после проверки типа и трактовать как требование интерфейса, а не как сертификат безопасности.', + 'Передать следующему слою только сгенерированный ключ, серверный MIME-тип и нужные метаданные; клиентское имя оставить за пределами файлового пути.', + ]), + heading('Ограничения'), + paragraph('Пример не является антивирусом и не делает опасный формат безопасным. Он также не ограничивает размер всего HTTP-запроса на уровне веб-сервера и PHP-конфигурации. Это нужно проверять отдельно: прикладной лимит защищает логику, а ограничения окружения — сам приём запроса. Если затем файл отдаётся другим пользователям, появляется ещё один самостоятельный вопрос: кто и по какому маршруту его читает.'), + heading('Проверяемые источники'), + sourceList([multipartRfc, phpUploadErrors, phpFileinfo, phpGetImageSize, owaspUpload]), + ].join('\n'), +}; + +const fieldArticle = { + slug: 'editorial-2018-03-field-safe-uploads', + title: 'PHP. Как отдать приватный файл владельцу и не сделать uploads публичной папкой', + categories: ['PHP', 'Безопасность'], + cover: '/assets/editorial/2018/php-private-download-flow.svg', + excerpt: 'Разбираем контролируемую выдачу документа: путь хранится вне веб-корня, доступ проверяется по записи в базе, а браузер получает содержимое только после авторизации.', + readingMinutes: 9, + contentHtml: [ + paragraph('Симптом: личный документ открывается по прямому URL из /uploads без повторной проверки пользователя. Цена ошибки — ссылка становится фактическим правом доступа и может раскрыть файл не тому человеку. Файл можно проверить при загрузке и всё равно потерять контроль над ним при выдаче. Типичный путь выглядит так: пользователь прикрепил документ, приложение положило его в /uploads, а ссылка стала чем-то вроде /uploads/ivan-passport.pdf. Теперь имя файла одновременно является адресом и фактически проверкой доступа. Для личного документа это слишком много ответственности у одной строки.'), + paragraph('Здесь разбираю один вопрос: как дать владельцу скачать приватный PDF, если сам файл лежит вне веб-корня? Это небольшой PHP 7.2-пример для внутренних документов. Он не пытается строить файловый сервис, а показывает границу: маршрут приложения решает доступ, файловая система хранит байты.'), + heading('У файла должны быть две разные сущности'), + paragraph('Пользовательский документ имеет понятное имя — «счёт за март.pdf». Хранилищу оно не нужно. Ему нужен стабильный ключ, который создаёт приложение: например, 32 шестнадцатеричных символа с расширением .pdf. В базе связываем ключ с владельцем и типом. HTTP-маршрут принимает только числовой ID записи, ищет её вместе с владельцем и уже потом открывает путь.'), + figure( + '/assets/editorial/2018/php-private-download-flow.svg', + 'Схема приватной выдачи: запрос к маршруту проходит авторизацию, запись в базе связывает владельца с ключом, PHP читает файл из закрытого каталога и отправляет ответ.', + 'Прямой путь к файлу не выдаётся браузеру. Авторизация остаётся до чтения с диска.', + ), + dataTable( + ['Слой', 'Что в нём храним', 'Чего в нём нет'], + [ + ['Таблица documents', 'id, owner_id, storage_key, статус', 'Публичного URL и пути, собранного из имени пользователя'], + ['Закрытый каталог', 'Файл по ключу, созданному приложением', 'Оригинального имени и логики авторизации'], + ['Маршрут /documents/{id}/download', 'Проверку текущего пользователя и HTTP-ответ', 'Свободного параметра path из запроса'], + ['Браузер', 'Содержимое файла после успешного ответа', 'Сведений о расположении файла на сервере'], + ], + ), + heading('Небольшой обработчик PDF'), + paragraph('Для ясности пример обслуживает только PDF. MIME-тип в ответе задан кодом, а не переписан из имени или запроса. Имя в Content-Disposition тоже фиксировано: задача заметки — доступ, а не универсальная передача пользовательских названий через заголовок. В реальном интерфейсе красивое имя можно хранить отдельно и добавлять в заголовок только после нормализации.'), + codeBlock(String.raw` +prepare( + 'SELECT storage_key + FROM documents + WHERE id = :id AND owner_id = :owner_id AND status = :status' + ); + $query->execute([ + ':id' => $documentId, + ':owner_id' => $currentUserId, + ':status' => 'ready', + ]); + $document = $query->fetch(PDO::FETCH_ASSOC); + + if (!$document) { + http_response_code(404); + exit; + } + + $key = (string)$document['storage_key']; + if (!preg_match('/\\A[a-f0-9]{32}\\.pdf\\z/', $key)) { + error_log('Некорректный ключ документа ' . $documentId); + http_response_code(404); + exit; + } + + $path = '/var/app/private-uploads/' . $key; + if (!is_file($path)) { + error_log('Не найден файл для документа ' . $documentId); + http_response_code(404); + exit; + } + + header('Content-Type: application/pdf'); + header('Content-Disposition: attachment; filename="document.pdf"'); + header('Content-Length: ' . filesize($path)); + + readfile($path); + exit; +} +`), + paragraph('SQL-запрос проверяет владельца вместе с ID документа. Поэтому путь на диске не зависит от значения из URL. Регулярное выражение кажется избыточным, но оно защищает код от испорченной записи в базе и фиксирует контракт ключа рядом с местом, где ключ превращается в путь. Если запись чужая или отсутствует, пример отвечает одинаковым 404; это решение уменьшает различие ответов, но журналировать такие случаи всё равно полезно.'), + heading('Как воспроизвести проверку'), + paragraph('На тестовой базе достаточно двух пользователей: Анны и Бориса. Создаём запись документа Анны со статусом ready и кладём тестовый PDF с соответствующим ключом в закрытый каталог. Затем повторяем одни и те же действия из двух сессий. Здесь важен не красивый экран, а наблюдаемые HTTP-ответы и отсутствие прямой ссылки на каталог.'), + orderedList([ + 'Анна запрашивает /documents/42/download: получает 200, заголовок Content-Type: application/pdf и байты тестового файла.', + 'Борис запрашивает тот же URL: получает 404, а тело файла не попадает в ответ.', + 'Запрос к предполагаемому пути /uploads/<storage_key> не должен находить файл, потому что каталог не лежит в веб-корне.', + 'Удаляем файл на диске при сохранённой записи: получаем 404 и запись в серверном журнале без абсолютного пути в ответе пользователю.', + 'Пробуем передать в URL похожий ID или строку вместо числа: роутер должен отклонить запрос до вызова функции.', + ]), + heading('Что будет, если оставить прямую ссылку'), + paragraph('Для публичной картинки прямой URL может быть нормальным контрактом. Для чека, договора или личного вложения он смешивает хранение с авторизацией: проверка пользователя происходит один раз при создании ссылки, а дальше файл живёт по адресу сам по себе. Закрытый каталог и маршрут не делают систему неуязвимой, зато возвращают проверку доступа в приложение, где есть пользователь, роль, статус документа и журнал.'), + heading('Ограничения этого решения'), + paragraph('У readfile простая задача — отдать содержимое файла в ответ. В примере нет поддержки диапазонов, кеширования, ограничения частоты загрузок и фоновой выдачи больших файлов. Для небольших PDF это хорошая стартовая точка. Для видео, больших архивов или заметного трафика потребуется передать доставку веб-серверу или файловому хранилищу, но проверку доступа и сопоставление ID с ключом нельзя потерять по дороге.'), + paragraph('Загрузка и выдача связаны, но не должны быть одной функцией. При загрузке приложение выбирает допустимый формат и ключ; при выдаче — проверяет владельца и формирует HTTP-ответ до любого вывода. PHP Manual отдельно напоминает, что header() вызывается до отправки тела ответа; поэтому в обработчике не должно быть случайного HTML или отладочного echo раньше заголовков.'), + heading('Проверяемые источники'), + sourceList([owaspUpload, phpHeader, phpReadfile, contentDispositionRfc]), + ].join('\n'), +}; + +export const revisions = [practiceArticle, mechanismArticle, fieldArticle]; + +if (process.argv[1]?.endsWith('/upgrade-2018-03.mjs')) { + if (process.argv.includes('--print-revisions')) { + process.stdout.write(JSON.stringify(revisions, null, 2) + '\n'); + } else { + process.stderr.write('Usage: node scripts/upgrade-2018-03.mjs --print-revisions\n'); + process.exitCode = 1; + } +} diff --git a/web/scripts/upgrade-2018-04.mjs b/web/scripts/upgrade-2018-04.mjs new file mode 100644 index 0000000..45d6def --- /dev/null +++ b/web/scripts/upgrade-2018-04.mjs @@ -0,0 +1,488 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const webRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const articlesPath = join(webRoot, 'data', 'articles.json'); + +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function paragraph(text) { + return '

' + text + '

'; +} + +function heading(text) { + return '

' + text + '

'; +} + +function codeBlock(lines) { + return '
' + escapeHtml(lines.join('\n')) + '
'; +} + +function figure(src, alt, caption) { + return '
' + alt + '
' + caption + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function bulletList(items) { + return '
    ' + items.map((item) => '
  • ' + item + '
  • ').join('') + '
'; +} + +function dataTable(headers, rows) { + const head = '' + headers.map((header) => '' + header + '').join('') + ''; + const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; + return '
' + head + body + '
'; +} + +function sourceList(items) { + return ''; +} + +function textFromHtml(html) { + return html + .replace(/<[^>]*>/g, ' ') + .replaceAll(' ', ' ') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replace(/\s+/g, ' ') + .trim(); +} + +const translit = { + title: 'Bitrix: CUtil::translit', + url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cutil/translit.php', + note: 'параметры нормализации строки: регистр, замена пробелов и повторяющихся разделителей', +}; + +const addElement = { + title: 'Bitrix: CIBlockElement::Add', + url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/add.php?print=Y', + note: 'создание элемента, поле CODE, возвращаемый ID и LAST_ERROR при ошибке', +}; + +const getList = { + title: 'Bitrix: CIBlockElement::GetList', + url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/getlist.php?print=Y', + note: 'выборка элементов по фильтрам IBLOCK_ID, CODE, ACTIVE и с заданным порядком', +}; + +const updateElement = { + title: 'Bitrix: CIBlockElement::Update', + url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/update.php?print=Y', + note: 'изменение полей существующего элемента и результат операции', +}; + +const parseComponentPath = { + title: 'Bitrix: CComponentEngine::ParseComponentPath', + url: 'https://dev.1c-bitrix.ru/api_help/main/reference/ccomponentengine/parsecomponentpath.php', + note: 'разбор ЧПУ-пути по шаблонам и восстановление переменных компонента', +}; + +const makePathFromTemplate = { + title: 'Bitrix: CComponentEngine::MakePathFromTemplate', + url: 'https://dev.1c-bitrix.ru/api_help/main/reference/ccomponentengine/makepathfromtemplate.php', + note: 'подстановка значений массива в маркеры URL-шаблона', +}; + +const drafts = [ + { + slug: 'editorial-2018-04-practice-bitrix-slugs', + title: 'Bitrix API. Символьный код: как не получить два одинаковых адреса', + categories: ['Bitrix', 'PHP', 'Практика'], + cover: '/assets/editorial/2018/bitrix-slug-build-2018.svg', + excerpt: 'Собираем символьный код элемента из имени, проверяем занятость в нужном инфоблоке и разбираем границу, за которой простой суффикс перестаёт быть защитой.', + readingMinutes: 10, + sources: [translit, getList, addElement], + bodyHtml: [ + paragraph('Добавляем товар в Bitrix и берём CODE из названия. На тесте всё выглядит хорошо. Потом менеджер заводит «Кофе Classic 250 г» второй раз — с запятой или лишним пробелом. После транслитерации получается тот же адрес, а ссылка из каталога ведёт к записи, которую никто не собирался открывать. Главный вопрос этой заметки простой: как получить читаемый код и не принять совпадение за успех?'), + paragraph('Сначала важная оговорка. Транслитерация не выбирает свободный URL. Она преобразует строку по заданным правилам. Уникальность — уже правило конкретного инфоблока и конкретного способа создания элементов. Поэтому проверяем не «красиво ли выглядит код», а есть ли другой элемент с тем же значением там, где его будет искать каталог.'), + heading('Что даёт системный транслит'), + paragraph('В Bitrix для этой задачи есть CUtil::translit. Метод принимает строку, язык и набор параметров. В нём можно задать регистр, замену пробелов и прочих символов, ограничение длины, а также удаление повторяющихся замен. Для адреса каталога мне удобнее дефис и нижний регистр: в результате не приходится отдельно объяснять, почему одни карточки имеют подчёркивание, а другие — дефис.'), + paragraph('Но нормализация не делает два разных названия разными. «Кофе Classic 250 г», «Кофе Classic-250 г» и «Кофе Classic 250 г» вполне могут прийти к одному кандидату. Это не ошибка CUtil::translit. Функция честно выполнила свою работу: привела вход к одному виду. Сравнивать и разрешать конфликт должен вызывающий код.'), + figure('/assets/editorial/2018/bitrix-slug-build-2018.svg', 'Схема построения символьного кода: имя, транслитерация, проверка через GetList, суффикс или создание элемента', 'Транслит формирует кандидата. Решение о свободном коде появляется только после проверки в нужном инфоблоке.'), + heading('Минимальный контракт'), + paragraph('Для одного каталога достаточно договориться о нескольких вещах до написания функции. Они не привязаны к шаблону страницы и не требуют большой переделки. Зато по ним сразу видно, почему повторный импорт изменил адрес или почему карточка попала не в тот раздел.'), + dataTable( + ['Шаг', 'Что считаем результатом', 'Что проверяем'], + [ + ['Имя', 'Есть непустое название', 'Не передаём в транслит пустую строку и не придумываем код из ID молча'], + ['Нормализация', 'Один предсказуемый кандидат', 'Регистр, дефис, длина и повторяющиеся разделители заданы явно'], + ['Поиск', 'Нет элемента с тем же CODE', 'Ищем внутри конкретного IBLOCK_ID, а не по всему сайту'], + ['Сохранение', 'Метод Add вернул ID', 'При ошибке сохраняем LAST_ERROR и исходное имя'], + ['Проверка ссылки', 'Каталог находит именно эту запись', 'Сверяем URL-шаблон и фильтр детального компонента'], + ], + ), + heading('Воспроизводимый пример'), + paragraph('Ниже функция для последовательного добавления из админки или небольшого импорта. Число 50 здесь не ограничение Bitrix, а мой предел для понятной ошибки: если за пятьдесят попыток не найден свободный вариант, лучше остановиться и посмотреть на входные данные. В реальном проекте ID инфоблока и правило суффикса стоит вынести в конфигурацию.'), + codeBlock([ + ' 90,', + ' "change_case" => "L",', + ' "replace_space" => "-",', + ' "replace_other" => "-",', + ' "delete_repeat_replace" => true,', + ' ));', + '', + ' $base = trim($base, "-");', + ' if ($base === "") {', + ' throw new InvalidArgumentException("Не удалось получить CODE из NAME");', + ' }', + '', + ' for ($number = 1; $number <= 50; $number++) {', + ' $candidate = $number === 1 ? $base : $base . "-" . $number;', + ' $result = CIBlockElement::GetList(', + ' array(),', + ' array("IBLOCK_ID" => (int)$iblockId, "=CODE" => $candidate),', + ' false,', + ' array("nTopCount" => 1),', + ' array("ID")', + ' );', + '', + ' if (!$result->Fetch()) {', + ' return $candidate;', + ' }', + ' }', + '', + ' throw new RuntimeException("Не найден свободный CODE за 50 попыток");', + '}', + ]), + paragraph('Знак = в фильтре делает намерение явным: мы ищем конкретный код, а не похожую строку. В выборку достаточно взять ID; имя, картинка и свойства для решения о занятости не нужны. Это маленькая деталь, но она не даёт диагностическому запросу превращаться в выборку всего каталога.'), + heading('Сохраняем код вместе с элементом'), + paragraph('После проверки не нужно делать отдельный Update ради CODE. Документация CIBlockElement::Add допускает поле CODE в массиве полей. Добавляю его в тот же вызов и обязательно разбираю ошибку. Возвращённый ID доказывает запись, но ещё не доказывает, что путь компонента совпадает с проектным URL.'), + codeBlock([ + 'Add(array(', + ' "IBLOCK_ID" => 12,', + ' "NAME" => $name,', + ' "CODE" => getFreeElementCode(12, $name),', + ' "ACTIVE" => "N",', + '));', + '', + 'if ($id === false) {', + ' throw new RuntimeException($element->LAST_ERROR);', + '}', + '', + '// Публикуем только после проверки обязательных данных и ссылки.', + ]), + heading('Последовательность проверки'), + orderedList([ + 'Взять два названия, которые различаются только знаками и пробелами, и получить для них кандидаты.', + 'Создать первый элемент на тестовом инфоблоке с исходным кандидатом.', + 'Запустить функцию для второго имени и убедиться, что она вернула суффикс, а не прежний код.', + 'Прочитать оба элемента через CIBlockElement::GetList с тем же IBLOCK_ID.', + 'Открыть детальные страницы и сверить ID в шаблоне или временном логе. Так мы проверяем не только данные, но и используемый компонентом маршрут.', + ]), + heading('Граница этого решения'), + paragraph('Проверка «сначала GetList, потом Add» не является атомарной. Два параллельных воркера могут одновременно увидеть свободный код и попытаться сохранить одинаковое значение. Для ручного ввода и последовательного импорта этого обычно достаточно. Для параллельной синхронизации нужен отдельный проектный механизм: очередь, блокировка или код, связанный со стабильным внешним идентификатором. Какой именно — зависит от версии Bitrix, базы и требований к существующим URL.'), + paragraph('Не стоит лечить эту задачу случайным числом в каждом коде. Такой адрес перестаёт быть повторяемым при повторном импорте, а диагностика становится сложнее. Если данные поставщика имеют стабильный артикул, полезно заранее решить, будет ли он участвовать в CODE или останется отдельным свойством. Главное — зафиксировать правило до публикации первой тысячи карточек.'), + heading('Итог'), + paragraph('Символьный код начинается с CUtil::translit, но не заканчивается на нём. Сначала делаем читаемого кандидата, затем проверяем его в нужном инфоблоке, сохраняем результат вместе с элементом и отдельно открываем ссылку. Такой порядок не решает гонку параллельного импорта, зато честно показывает её границу и избавляет от тихих совпадений в обычной работе.'), + ].join('\n'), + }, + { + slug: 'editorial-2018-04-mechanism-bitrix-slugs', + title: 'Bitrix API. Как адрес каталога превращается в ELEMENT_CODE', + categories: ['Bitrix', 'PHP', 'ЧПУ'], + cover: '/assets/editorial/2018/bitrix-slug-route-2018.svg', + excerpt: 'Разбираем, где ЧПУ-путь становится переменной компонента, почему URL-шаблон не равен запросу к инфоблоку и как проверить связку без гадания по кешу.', + readingMinutes: 10, + sources: [parseComponentPath, makePathFromTemplate, getList], + bodyHtml: [ + paragraph('Иногда символьный код в элементе правильный, а карточка всё равно отвечает 404. В другой раз тот же код работает только без раздела в адресе. Причина обычно не в транслите: путь сначала разбирает компонент, а уже потом его переменные попадают в фильтр инфоблока. Разберём один вопрос: что должно совпасть, чтобы адрес каталога действительно стал значением ELEMENT_CODE?'), + paragraph('Это полезно отделить в голове. Адрес /catalog/kofe/classic-250-g/ не является запросом к таблице элементов. Для комплексного компонента Bitrix сначала определяет, какой шаблон пути подошёл, и восстанавливает переменные из URL. Только затем код компонента решает, как искать элемент. Если смешать эти два шага, начинается бесконечная правка CODE, хотя ошибка сидит в шаблоне или в имени переменной.'), + heading('Что делает движок ЧПУ'), + paragraph('В документации CComponentEngine::ParseComponentPath описано, что метод получает папку ЧПУ, массив шаблонов и текущий путь. Он возвращает код найденного шаблона, а переменные из пути записывает в переданный массив. Если шаблон не найден, результат — пустая строка. Значит, до запроса к инфоблоку можно и нужно посмотреть две вещи: какой шаблон распознан и какое значение оказалось в ELEMENT_CODE.'), + paragraph('Шаблон пишется относительно папки компонента. Например, для папки /catalog/ внутри массива нужен путь #SECTION_CODE#/#ELEMENT_CODE#/, а не полный адрес с начальным слешем. Это не вкусовщина: документация отдельно предупреждает, что лишний слеш в шаблоне меняет результат разбора.'), + figure('/assets/editorial/2018/bitrix-slug-route-2018.svg', 'Схема: запрос браузера разбирается SEF-шаблоном, превращается в SECTION_CODE и ELEMENT_CODE, затем используется в выборке', 'Переменная из URL и элемент инфоблока живут на разных шагах. Между ними стоит проектный фильтр компонента.'), + heading('Четыре значения, которые должны совпасть'), + dataTable( + ['Участок', 'Пример', 'Как проверить'], + [ + ['Папка ЧПУ', '/catalog/', 'Сравнить с SEF_FOLDER вызванного компонента'], + ['Шаблон детали', '#SECTION_CODE#/#ELEMENT_CODE#/', 'Проверить отсутствие лишнего начального слеша и нужные маркеры'], + ['Переменная', 'ELEMENT_CODE = classic-250-g', 'Вывести массив, полученный после разбора, на тестовой среде'], + ['Выборка', 'IBLOCK_ID + CODE + ACTIVE', 'Сравнить фильтр компонента с контрольным GetList'], + ['Ссылка в шаблоне', 'Тот же набор маркеров', 'Собрать URL из значений и открыть его вручную'], + ], + ), + heading('Минимальный воспроизводимый разбор'), + paragraph('Ниже не готовый комплексный компонент, а короткая проверка его основания. Запускаю её на тестовой странице с известным путём. Если $page не равен detail, до запроса к инфоблоку дело вообще не дошло. Если код страницы найден, но ELEMENT_CODE пуст, виноват шаблон или сам адрес.'), + codeBlock([ + ' "#SECTION_CODE#/#ELEMENT_CODE#/",', + ');', + '$arVariables = array();', + '', + '$page = CComponentEngine::ParseComponentPath(', + ' "/catalog/",', + ' $arUrlTemplates,', + ' $arVariables,', + ' "/catalog/kofe/classic-250-g/"', + ');', + '', + 'if ($page !== "detail" || empty($arVariables["ELEMENT_CODE"])) {', + ' throw new RuntimeException("URL не разобран как детальная страница");', + '}', + '', + '$result = CIBlockElement::GetList(', + ' array(),', + ' array(', + ' "IBLOCK_ID" => 12,', + ' "=CODE" => $arVariables["ELEMENT_CODE"],', + ' "ACTIVE" => "Y",', + ' ),', + ' false,', + ' array("nTopCount" => 1),', + ' array("ID", "NAME", "CODE")', + ');', + '', + '$element = $result->GetNext();', + 'if (!$element) {', + ' throw new RuntimeException("URL разобран, но элемент не найден");', + '}', + ]), + paragraph('В примере я специально оставил фильтр небольшим. Реальный каталог может добавить раздел, права, цену, наличие или свойство витрины. Эти условия нельзя угадывать из адреса. Их нужно взять из конкретного компонента и применить в контрольной выборке. Иначе тест будет доказывать только то, что элемент вообще существует, а не то, что его видит пользователь.'), + heading('Почему генерация и разбор должны пользоваться одной формой адреса'), + paragraph('Метод CComponentEngine::MakePathFromTemplate подставляет значения массива в маркеры шаблона. Это удобная точка для проверки обратного направления: у нас есть SECTION_CODE и ELEMENT_CODE, собираем путь и затем разбираем его тем же шаблоном. Если после такого круга переменная изменилась или пропала, в коде сайта уже есть расхождение.'), + codeBlock([ + ' "kofe",', + ' "ELEMENT_CODE" => "classic-250-g",', + ' )', + ');', + '', + '// $url: kofe/classic-250-g/', + '// Для ссылки добавляем папку /catalog/ в одном месте проекта.', + ]), + heading('Последовательность от ссылки до карточки'), + orderedList([ + 'Взять реальный адрес, который не открывается, и сохранить его без ручной правки.', + 'Сверить папку и шаблон детали в параметрах вызванного компонента.', + 'На тестовой среде вывести код страницы и массив переменных после ParseComponentPath.', + 'Передать полученный ELEMENT_CODE в короткий CIBlockElement::GetList с теми же базовыми фильтрами.', + 'Если элемент найден, сравнить с фильтром самого компонента: раздел, активность, права и проектные свойства.', + 'Собрать обратную ссылку из тех же маркеров и повторить проверку после изменения шаблона.', + ]), + heading('Частые расхождения'), + dataTable( + ['Симптом', 'Где искать', 'Безопасная проверка'], + [ + ['Страница не определяется', 'Папка ЧПУ или шаблон детали', 'Проверить результат ParseComponentPath до обращения к инфоблоку'], + ['Страница определяется, код пуст', 'Маркер отличается от имени, которое ждёт компонент', 'Сравнить ключи массива переменных с параметрами компонента'], + ['Код есть, элемента нет', 'CODE, инфоблок, активность или дополнительный фильтр', 'Запустить GetList сначала с базовыми, затем с проектными условиями'], + ['Ссылка формируется иначе, чем разбирается', 'Два разных URL-шаблона в шаблоне и компоненте', 'Собрать путь через MakePathFromTemplate и разобрать его обратно'], + ], + ), + heading('Ограничения'), + paragraph('Эта диагностика начинается в момент, когда PHP-компонент уже получил запрос. Если веб-сервер или правила перенаправления не передали путь в приложение, ParseComponentPath не сможет это исправить. Тогда проверять нужно предыдущий слой: фактический URI, правило маршрутизации и точку входа сайта. Не стоит менять CODE, пока не доказано, что компонент вообще получил нужную переменную.'), + paragraph('Ещё одна ловушка — перенос чужого шаблона без понимания его маркеров. В Bitrix можно назвать переменные по-разному, но компонент и его фильтр должны читать то же имя, которое восстановлено из пути. Я бы не делал универсальную функцию для всех страниц сайта: лучше зафиксировать один шаблон рядом с конкретным каталогом и покрыть его двумя-тремя адресами из реальных данных.'), + heading('Итог'), + paragraph('ЧПУ — это не «красивый CODE в базе», а связка из папки, шаблона, восстановленных переменных и фильтра элемента. Когда ссылка ведёт в 404, сначала смотрим результат разбора URL, затем выборку. После такой проверки становится видно, нужна ли правка в данных, компоненте или маршруте.'), + ].join('\n'), + }, + { + slug: 'editorial-2018-04-field-bitrix-slugs', + title: 'Bitrix API. Карточка открывает не тот товар: проверяем конфликт CODE', + categories: ['Bitrix', 'PHP', 'Диагностика'], + cover: '/assets/editorial/2018/bitrix-slug-conflict-2018.svg', + excerpt: 'Полевой разбор ситуации, когда адрес детали показывает другой элемент: считаем совпадения по CODE, сравниваем переменную ЧПУ с фильтром и меняем данные без потери следов.', + readingMinutes: 11, + sources: [getList, parseComponentPath, updateElement], + bodyHtml: [ + paragraph('Есть неприятная ошибка, которую легко принять за кеш: открываешь карточку товара, а видишь другой товар с похожим названием. Особенно странно это выглядит после импорта — обе записи есть в админке, у обеих нормальные картинки, а URL одной вдруг показывает соседнюю. Здесь не нужно начинать с очистки кеша. Главный вопрос: как доказать конфликт CODE или широкий фильтр до того, как менять данные?'), + paragraph('Первое правило — не смотреть только на название. Компонент получает строку из адреса и строит по ней выборку. Если выборка возвращает несколько элементов, значение «первого» зависит от порядка и условий запроса. Если она не возвращает ничего, компонент может отдать 404 или подставить другую ветку своей логики. Поэтому нам нужны три наблюдаемых факта: что было в URL, какую переменную получил компонент и сколько записей удовлетворяют его фильтру.'), + heading('Не путать симптом и причину'), + paragraph('Похожее название не доказывает конфликт. В одном каталоге может быть несколько позиций «Classic 250 г» в разных разделах, и тогда адрес обязан содержать достаточный контекст. Наоборот, разные названия могут получить одинаковый код после нормализации. Диагностику начинаю с конкретного сломанного адреса и ID товара, который ожидали увидеть. Только потом читаю список элементов по фактическому ELEMENT_CODE.'), + figure('/assets/editorial/2018/bitrix-slug-conflict-2018.svg', 'Дерево диагностики неправильной карточки: путь, переменная ELEMENT_CODE, число совпадений GetList и дальнейшие действия', 'Сначала считаем набор совпадений. Кеш проверяем только после пути и данных.'), + heading('Какие данные собрать до исправления'), + dataTable( + ['Факт', 'Зачем он нужен', 'Как зафиксировать'], + [ + ['Исходный URL', 'Показывает, что реально запросил браузер', 'Сохранить полный путь из адресной строки или access-лога'], + ['Ожидаемый ID', 'Не даёт спорить о том, какая запись считается правильной', 'Взять ID из админки или из результата импорта'], + ['ELEMENT_CODE', 'Связывает путь с данными компонента', 'Вывести переменную после разбора ЧПУ на тестовом стенде'], + ['Все записи по CODE', 'Отличает один результат от конфликта', 'Сделать ограниченный GetList в том же инфоблоке'], + ['Фильтр детали', 'Объясняет, почему часть записей исключена или выбрана', 'Сверить с параметрами и кодом конкретного компонента'], + ], + ), + heading('Контрольная выборка'), + paragraph('Документация CIBlockElement::GetList позволяет явно задать сортировку, фильтр, ограничение и набор полей. Для диагностики беру только те поля, которые помогают отличить записи: ID, имя, CODE, основной раздел и шаблон детального URL. Запрос не должен случайно тянуть свойства всего каталога: его задача — показать размер набора и порядок элементов.'), + codeBlock([ + ' "ASC"),', + ' array(', + ' "IBLOCK_ID" => (int)$iblockId,', + ' "=CODE" => $code,', + ' "ACTIVE" => "Y",', + ' ),', + ' false,', + ' array("nTopCount" => 20),', + ' array("ID", "NAME", "CODE", "IBLOCK_SECTION_ID", "DETAIL_PAGE_URL")', + ' );', + '', + ' $items = array();', + ' while ($item = $result->GetNext()) {', + ' $items[] = $item;', + ' }', + '', + ' return $items;', + '}', + '', + '$items = findActiveElementsByCode(12, "classic-250-g");', + 'if (count($items) !== 1) {', + ' throw new RuntimeException("Нужно разобрать " . count($items) . " совпадений");', + '}', + ]), + paragraph('Сортировка по ID в этом примере нужна не для выбора «правильного» товара, а для повторяемого вывода. Если там два элемента, проблема уже доказана: детальный компонент не должен случайно решать, что меньший ID важнее. Дальше либо сужаем фильтр контекстом раздела, либо исправляем один из кодов по заранее выбранному правилу.'), + heading('Как отличить три разных случая'), + dataTable( + ['Результат проверки', 'Что это значит', 'Следующий шаг'], + [ + ['0 совпадений', 'URL разобран, но элемент не проходит базовый фильтр', 'Проверить значение переменной, активность, инфоблок и шаблон ссылки'], + ['1 совпадение, ID правильный', 'Данные и базовый фильтр совпали', 'Сравнить дополнительные условия компонента и только затем кеш'], + ['1 совпадение, ID другой', 'Переменная из URL не соответствует ожидаемому товару', 'Проверить генерацию URL, шаблон и исходный CODE элемента'], + ['2 и более совпадений', 'Фильтр недостаточно точный или коды конфликтуют', 'Решить, нужен ли контекст раздела, затем изменить конфликтующие данные'], + ], + ), + heading('Проверяем, что компонент получил из URL'), + paragraph('Не нужно угадать имя переменной по шаблону. Комплексный компонент разбирает путь через CComponentEngine::ParseComponentPath и возвращает переменные, восстановленные из маркеров. Для проблемной ссылки полезно на тестовой копии вывести $page и $arVariables. Так видно, не потерялся ли раздел и действительно ли ELEMENT_CODE равен строке из адреса.'), + codeBlock([ + ' "#SECTION_CODE#/#ELEMENT_CODE#/",', + ');', + '$variables = array();', + '$page = CComponentEngine::ParseComponentPath(', + ' "/catalog/",', + ' $templates,', + ' $variables,', + ' "/catalog/kofe/classic-250-g/"', + ');', + '', + 'if ($page !== "detail") {', + ' throw new RuntimeException("Не найден шаблон detail");', + '}', + '', + 'error_log(print_r($variables, true));', + ]), + paragraph('Если в массиве нет SECTION_CODE, а детальный запрос должен учитывать раздел, коды элементов могут быть вполне корректны. Ошибка будет в URL-шаблоне или в логике компонента, который не применяет восстановленную переменную. И наоборот: если переменные верны, а GetList возвращает несколько записей, искать надо в данных и условиях выборки, не в роутинге.'), + heading('Исправление без потери истории'), + paragraph('Когда конфликт подтверждён, сначала выбираю правило для нового адреса: суффикс, артикул или раздел. Затем сохраняю старый URL и список мест, которые на него ссылаются. Смена CODE меняет адрес, поэтому публикацию лучше выполнять отдельным шагом с проверкой ссылок. Метод CIBlockElement::Update возвращает результат изменения; при ошибке не пропускаем LAST_ERROR.'), + codeBlock([ + 'Update($duplicateId, array(', + ' "CODE" => "classic-250-g-2",', + '));', + '', + 'if (!$updated) {', + ' throw new RuntimeException($element->LAST_ERROR);', + '}', + '', + '// После изменения снова выполняем findActiveElementsByCode().', + ]), + heading('Порядок работы в продовой задаче'), + orderedList([ + 'Зафиксировать URL, ожидаемый ID и время, когда ошибка наблюдалась.', + 'На тестовой копии получить переменные, восстановленные из того же пути.', + 'Сделать выборку по фактическому CODE в нужном IBLOCK_ID и посчитать результаты.', + 'Сравнить полученные ID с тем, что показывает детальный компонент после его дополнительных фильтров.', + 'Если есть конфликт, выбрать новое стабильное правило кода и проверить все старые ссылки, которые важны для проекта.', + 'После изменения повторить URL-проверку. Кеш и индекс обновлять только по принятому в проекте порядку, когда данные и маршрут уже доказаны.', + ]), + heading('Ограничения'), + paragraph('Эта заметка не утверждает, что любое совпадение CODE ошибочно. В некоторых каталогах один и тот же код допустим в разных витринах или разделах, и тогда адрес и фильтр обязаны включать этот контекст. Не следует добавлять раздел в запрос автоматически: сначала нужно понять, что именно считает идентичностью текущий компонент.'), + paragraph('Также не стоит менять десятки кодов одной SQL-командой. У Bitrix есть API изменения элемента, обработчики событий и проектные зависимости от адресов. Сначала правим один доказанный конфликт на тестовых данных, проверяем маршрут и только потом составляем отдельный план для массовой миграции.'), + heading('Итог'), + paragraph('Когда адрес открывает не тот товар, удобнее не спорить о кеше, а посчитать факты. URL даёт переменную, переменная даёт набор элементов, набор показывает — это маршрут, фильтр или конфликт данных. После такой проверки изменение CODE становится осознанной операцией, а не попыткой наугад исправить карточку.'), + ].join('\n'), + }, +]; + +const archive = JSON.parse(await readFile(articlesPath, 'utf8')); +const archiveSlugs = new Set(archive.map((article) => article.slug)); +const requiredSlugs = [ + 'editorial-2018-04-practice-bitrix-slugs', + 'editorial-2018-04-mechanism-bitrix-slugs', + 'editorial-2018-04-field-bitrix-slugs', +]; + +for (const slug of requiredSlugs) { + if (!archiveSlugs.has(slug)) { + throw new Error('Article not found: ' + slug); + } +} + +const reports = drafts.map((draft) => { + const bodyLength = textFromHtml(draft.bodyHtml).length; + const sourceCount = draft.sources.length; + + if (bodyLength < 5000 || bodyLength > 15000) { + throw new Error('Body length outside 5,000–15,000 characters: ' + draft.slug + ' (' + bodyLength + ')'); + } + if (sourceCount < 2) { + throw new Error('At least two primary sources are required: ' + draft.slug); + } + if (!draft.bodyHtml.includes('
') || !draft.bodyHtml.includes('') || !draft.bodyHtml.includes('
')) {
+    throw new Error('Figure, table or reproducible code is missing: ' + draft.slug);
+  }
+
+  return {
+    slug: draft.slug,
+    bodyCharacters: bodyLength,
+    sourceCount,
+    hasFigure: true,
+    hasTable: true,
+    hasCode: true,
+  };
+});
+
+const revisions = drafts.map((draft) => {
+  const { bodyHtml, sources, ...revision } = draft;
+  return {
+    ...revision,
+    contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'),
+  };
+});
+
+if (process.argv.includes('--print-revisions')) {
+  console.log(JSON.stringify(revisions, null, 2));
+} else if (process.argv.includes('--check')) {
+  console.log(JSON.stringify(reports, null, 2));
+} else {
+  console.error('Usage: node web/scripts/upgrade-2018-04.mjs --print-revisions | --check');
+  process.exitCode = 1;
+}
diff --git a/web/scripts/upgrade-2018-05.mjs b/web/scripts/upgrade-2018-05.mjs
new file mode 100644
index 0000000..603e590
--- /dev/null
+++ b/web/scripts/upgrade-2018-05.mjs
@@ -0,0 +1,558 @@
+function escapeHtml(value) {
+  return String(value)
+    .replaceAll('&', '&')
+    .replaceAll('<', '<')
+    .replaceAll('>', '>')
+    .replaceAll('"', '"')
+    .replaceAll("'", ''');
+}
+
+function paragraph(text) {
+  return '

' + text + '

'; +} + +function heading(text) { + return '

' + text + '

'; +} + +function codeBlock(code) { + return '
' + escapeHtml(String(code).trim()) + '
'; +} + +function figure(src, alt, caption) { + return '
' + alt + '
' + caption + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function bulletList(items) { + return '
    ' + items.map((item) => '
  • ' + item + '
  • ').join('') + '
'; +} + +function dataTable(headers, rows) { + const head = '
' + headers.map((header) => '').join('') + ''; + const body = '' + rows.map((row) => '' + row.map((cell) => '').join('') + '').join('') + ''; + return '
' + header + '
' + cell + '
' + head + body + '
'; +} + +function sourceList(items) { + return ''; +} + +function visibleText(html) { + return html + .replace(/<[^>]*>/g, ' ') + .replaceAll(' ', ' ') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replace(/\s+/g, ' ') + .trim(); +} + +function createRevision(meta, bodyParts, sources) { + const bodyHtml = bodyParts.join('\n'); + const bodyLength = visibleText(bodyHtml).length; + + if (bodyLength < 5000 || bodyLength > 15000) { + throw new Error(meta.slug + ': body length must be 5000–15000, got ' + bodyLength); + } + + const contentHtml = [ + bodyHtml, + heading('Проверяемые источники'), + sourceList(sources), + ].join('\n'); + + for (const requiredFragment of ['
', '', '
']) {
+    if (!contentHtml.includes(requiredFragment)) {
+      throw new Error(meta.slug + ': missing required fragment ' + requiredFragment);
+    }
+  }
+
+  if (sources.length < 2) {
+    throw new Error(meta.slug + ': at least two primary sources are required');
+  }
+
+  return {
+    ...meta,
+    contentHtml,
+    bodyLength,
+  };
+}
+
+const jqueryOn = {
+  title: 'jQuery API: .on()',
+  url: 'https://api.jquery.com/on/',
+  note: 'прямая и делегированная привязка, пространства имён, повторная привязка и ограничения делегирования',
+};
+
+const jqueryOff = {
+  title: 'jQuery API: .off()',
+  url: 'https://api.jquery.com/off/',
+  note: 'снятие обработчика по типу события, селектору и пространству имён',
+};
+
+const jqueryHtml = {
+  title: 'jQuery API: .html()',
+  url: 'https://api.jquery.com/html/',
+  note: 'замена содержимого, удаление событий дочерних узлов и риск вставки непроверенной HTML-строки',
+};
+
+const jqueryAjax = {
+  title: 'jQuery API: jQuery.ajax()',
+  url: 'https://api.jquery.com/jQuery.ajax/',
+  note: 'jqXHR, обработчики done/fail/always, timeout и порядок завершения запроса',
+};
+
+const jquerySerialize = {
+  title: 'jQuery API: .serialize()',
+  url: 'https://api.jquery.com/serialize/',
+  note: 'какие поля формы попадают в URL-кодированную строку и почему файлы в неё не входят',
+};
+
+const jqueryProp = {
+  title: 'jQuery API: .prop()',
+  url: 'https://api.jquery.com/prop/',
+  note: 'динамические свойства disabled и checked в jQuery 1.6+',
+};
+
+const jqueryData = {
+  title: 'jQuery API: .data()',
+  url: 'https://api.jquery.com/data/',
+  note: 'хранение состояния рядом с DOM-узлом',
+};
+
+const jqueryRemoveData = {
+  title: 'jQuery API: .removeData()',
+  url: 'https://api.jquery.com/removeData/',
+  note: 'удаление ранее сохранённого значения из внутреннего хранилища jQuery',
+};
+
+const jqueryAlways = {
+  title: 'jQuery API: deferred.always()',
+  url: 'https://api.jquery.com/deferred.always/',
+  note: 'обработчик, который вызывается и после resolve, и после reject; подходит для освобождения интерфейса',
+};
+
+const practiceArticle = createRevision(
+  {
+    slug: 'editorial-2018-05-practice-legacy-jquery',
+    title: 'jQuery. Как повторно инициализировать виджет и не получить два клика',
+    categories: ['JavaScript', 'jQuery', 'Практика'],
+    cover: '/assets/editorial/2018/jquery-reinit-namespaces.svg',
+    excerpt: 'Разбираем маленький контракт для legacy-виджета: повторный mount снимает только свои события, назначает один обработчик и проверяется тремя вызовами подряд.',
+    readingMinutes: 9,
+  },
+  [
+    paragraph('В старом интерфейсе блок заказа часто обновляется без перезагрузки страницы. После ответа Ajax мы заново вызываем mountOrderForm, потому что так проще, чем помнить все места, где изменилась разметка. Через неделю один клик по кнопке уходит двумя запросами. Через месяц — тремя. Ошибка неприятна не из-за консоли: одна пользовательская команда может несколько раз изменить состояние на сервере.'),
+    paragraph('Главный вопрос здесь узкий: как написать инициализацию jQuery-виджета так, чтобы её можно было вызвать повторно и на кнопке оставался ровно один наш обработчик? Не будем переписывать весь legacy-код. Достаточно сделать явный контракт у одной функции mount и проверить его в браузере.'),
+    heading('Почему обработчик умножается'),
+    paragraph('Метод .on() привязывает обработчик к текущей выбранной коллекции. Если один и тот же код вызвать ещё раз, старый обработчик сам не исчезает. Официальная документация jQuery отдельно отмечает, что один обработчик можно привязать к элементу несколько раз. Поэтому проблема не в Ajax как таковом, а в функции, которая при каждом вызове только добавляет новое событие.'),
+    paragraph('Плохой вариант обычно выглядит безобидно. Его легко не заметить, когда страница открывается один раз и только руками.'),
+    codeBlock(String.raw`
+function mountOrderForm() {
+  $('.js-order-submit').on('click', function (event) {
+    event.preventDefault();
+    sendOrder();
+  });
+}
+
+mountOrderForm();
+mountOrderForm(); // теперь у каждой найденной кнопки два обработчика
+`),
+    paragraph('Не надо лечить это глобальным off("click"). Такой вызов снимет и события соседнего кода, который может не иметь отношения к форме. Сначала нужно дать событиям нашего виджета собственное имя. В jQuery пространство имён не является иерархией, но позволяет снять обработчики по имени, не трогая чужие click-события.'),
+    figure('/assets/editorial/2018/jquery-reinit-namespaces.svg', 'Три шага повторной инициализации jQuery-виджета: снять обработчики с пространством имён, затем назначить один новый', 'Повторный вызов mount сначала очищает только события конкретного виджета, затем создаёт один обработчик.'),
+    heading('Контракт функции mount'),
+    paragraph('Для этого примера договоримся о трёх вещах. Контейнер #order-panel существует до вызова функции. Все события виджета получают пространство имён .orderForm. После выполнения функции у контейнера есть ровно один делегированный обработчик для кнопки отправки. Такая формулировка важнее названия функции: по ней можно проверить результат и не спорить о том, достаточно ли «аккуратно» написан код.'),
+    dataTable(
+      ['Условие', 'Действие mount', 'Ожидаемый результат', 'Чего не делаем'],
+      [
+        ['Контейнер уже есть в DOM', 'Работаем от #order-panel', 'Есть стабильная граница виджета', 'Не ищем кнопку по всему документу'],
+        ['mount вызван повторно', 'Снимаем .orderForm с контейнера', 'Старый обработчик виджета исчезает', 'Не вызываем off("click")'],
+        ['Кнопка появилась позже', 'Используем селектор во втором аргументе .on()', 'Клик новой кнопки доходит до контейнера', 'Не перепривязываем всё дерево после каждой мелочи'],
+        ['Соседний код слушает click', 'Оставляем чужое пространство имён нетронутым', 'Другой модуль продолжает работать', 'Не полагаемся на порядок загрузки скриптов'],
+      ],
+    ),
+    heading('Рабочий пример'),
+    paragraph('В коде ниже обработчик висит на постоянном контейнере, а не на самой кнопке. Это небольшое делегирование: jQuery проверит, что событие пришло от потомка с классом .js-order-submit. Подробно о том, почему это полезно при замене разметки, поговорим в следующей заметке; здесь важно другое — перед новым .on() мы удаляем только обработчики нашей зоны.'),
+    codeBlock(String.raw`
+(function ($) {
+  var eventNamespace = '.orderForm';
+
+  function sendOrder($button) {
+    // В проекте здесь будет Ajax-вызов или событие в общий слой.
+    window.console.count('order request');
+    $button.addClass('is-pending');
+  }
+
+  function mountOrderForm(root) {
+    var $root = $(root);
+
+    if ($root.length !== 1) {
+      throw new Error('Нужен один контейнер формы заказа');
+    }
+
+    $root.off(eventNamespace);
+    $root.on('click' + eventNamespace, '.js-order-submit', function (event) {
+      event.preventDefault();
+      sendOrder($(this));
+    });
+  }
+
+  window.mountOrderForm = mountOrderForm;
+}(jQuery));
+
+mountOrderForm('#order-panel');
+`),
+    paragraph('Вызов $root.off(eventNamespace) затрагивает все события с пространством .orderForm на этом контейнере. Это удобно, когда у виджета несколько собственных событий: например, click.orderForm и change.orderForm. Но имя должно быть достаточно конкретным. Если два независимых скрипта выберут одно и то же .form, они начнут снимать события друг друга.'),
+    heading('Воспроизводимая проверка без сервера'),
+    paragraph('Не нужно ждать настоящего API, чтобы увидеть дефект. В консоли страницы можно собрать короткий счётчик и трижды вызвать тестовый mount. Если после одного программного клика счётчик равен единице, контракт выполнен. Если он равен трём, проблема остаётся на фронтенде и сервер здесь пока ни при чём.'),
+    codeBlock(String.raw`
+var calls = 0;
+var $panel = $('');
+
+function mountDemo(root) {
+  var $root = $(root);
+
+  $root.off('.demoOrder');
+  $root.on('click.demoOrder', '.js-order-submit', function (event) {
+    event.preventDefault();
+    calls += 1;
+  });
+}
+
+$('body').append($panel);
+mountDemo('#order-panel');
+mountDemo('#order-panel');
+mountDemo('#order-panel');
+
+$panel.find('.js-order-submit').trigger('click');
+window.console.assert(calls === 1, 'Нужен один обработчик, получено: ' + calls);
+
+$panel.remove();
+`),
+    paragraph('В рабочем проекте вместо подмены console.count полезнее вынести обработчик в именованную функцию и проверить количество вызовов тестом. Но даже такой короткий сценарий дисциплинирует: он проверяет не внешний вид кнопки, а свойство инициализации при повторном запуске.'),
+    heading('Когда вызывать mount'),
+    paragraph('Я бы вызывал функцию в двух местах: после начальной загрузки страницы и после того кода, который действительно заменил или добавил разметку внутри #order-panel. Не нужно размещать вызов в каждом Ajax-обработчике приложения «на всякий случай». Чем меньше мест создают виджет, тем проще понять, почему он существует на странице.'),
+    paragraph('Если обновление заменяет сам #order-panel, старый контейнер вместе со своими событиями уйдёт из DOM. Тогда нужно передать в mountOrderForm уже новый контейнер после вставки. Если же постоянным остаётся внешний блок, лучше выбрать его корнем и менять только внутреннюю разметку. Это решение не универсально: оно зависит от того, какой узел реально переживает обновление.'),
+    heading('Последовательность внедрения'),
+    orderedList([
+      'Найти функцию, которая сейчас повторно вешает события, и назвать один постоянный контейнер виджета.',
+      'Выбрать уникальное пространство имён, например .orderForm или .cartItem, а не общее .click.',
+      'Перед каждым назначением вызвать off только для этого пространства имён на выбранном контейнере.',
+      'Назначить обработчик через on и, если кнопки меняются, передать селектор потомка.',
+      'Трижды вызвать mount и одним кликом подтвердить, что полезное действие срабатывает один раз.',
+      'Отдельно проверить реальный серверный сценарий: клиентская защита не должна быть единственным барьером повторной операции.',
+    ]),
+    heading('Ограничения'),
+    bulletList([
+      'Этот приём требует jQuery 1.7 или новее, потому что использует .on() и .off(). Если проект закреплён на более старой версии, сначала надо зафиксировать допустимый путь обновления или отдельный совместимый адаптер.',
+      'Пространство имён защищает только события в браузере. Оно не отменяет уже отправленный запрос и не делает серверную операцию безопасной при повторе страницы, таймауте или ручном запросе.',
+      'Делегирование работает для событий, которые доходят до выбранного предка. Для особых типов событий и SVG у jQuery есть ограничения; их надо проверять по документации, а не переносить этот шаблон вслепую.',
+      'Если виджет начинает управлять десятком независимых состояний, одного обработчика уже мало. Сначала стоит разделить маленькие функции, а не превращать mountOrderForm в глобальный диспетчер.',
+    ]),
+    heading('Итог'),
+    paragraph('Повторная инициализация не обязана быть опасной. Ей нужен простой договор: устойчивый корень, собственное пространство имён и проверка «несколько mount — один клик». Этот договор легко показать коллеге, а при следующей Ajax-правке не придётся угадывать, сколько обработчиков уже живёт на кнопке.'),
+  ],
+  [jqueryOn, jqueryOff],
+);
+
+const mechanismArticle = createRevision(
+  {
+    slug: 'editorial-2018-05-mechanism-legacy-jquery',
+    title: 'jQuery. Почему кнопка перестаёт работать после .html()',
+    categories: ['JavaScript', 'jQuery', 'DOM'],
+    cover: '/assets/editorial/2018/jquery-delegation-after-html.svg',
+    excerpt: 'Разбираем, почему прямой обработчик исчезает вместе с заменённой разметкой, как выбрать устойчивый контейнер для делегирования и где этот приём не подходит.',
+    readingMinutes: 9,
+  },
+  [
+    paragraph('Каталог отрисовал новую страницу товаров через Ajax: #products получил свежий HTML, карточки на экране есть, но кнопка «В корзину» больше не реагирует. Первая реакция обычно понятна — ещё раз вызвать функцию, которая вешает click. После пары таких правок появляются уже две проблемы: у новых кнопок нет обработчика до следующей инициализации, а у старых он начинает дублироваться.'),
+    paragraph('Главный вопрос этой заметки: почему обработчик пропадает после .html() и как выбрать делегирование так, чтобы оно пережило замену карточек? Здесь важно не запомнить «вешай всё на document», а увидеть, на каком DOM-узле реально хранится обработчик и какой узел переживает обновление.'),
+    heading('Что делает .html() с прежней разметкой'),
+    paragraph('Когда .html(строка) задаёт новое содержимое, jQuery полностью заменяет прежних потомков контейнера. Документация отдельно предупреждает: перед заменой jQuery удаляет из дочерних элементов данные и обработчики событий. Поэтому прямой click на старой кнопке не «ломается» — он остаётся на старом DOM-узле, которого больше нет. Новая кнопка похожа внешне, но для браузера это другой объект.'),
+    paragraph('В этом легко убедиться на коротком примере. Сначала обработчик привязан непосредственно к найденной кнопке. После замены HTML в контейнере новая кнопка появляется без этого обработчика.'),
+    codeBlock(String.raw`
+var $products = $('#products');
+
+function buy(event) {
+  event.preventDefault();
+  window.console.log('Товар добавлен');
+}
+
+$products.find('.js-buy').on('click', buy);
+
+$products.html('Купить');
+
+// Эта новая ссылка создана после .on(), поэтому buy для неё не назначен.
+$products.find('.js-buy').trigger('click');
+`),
+    paragraph('Это не повод каждый раз обходить все кнопки после рендера. Прямая привязка нормальна, когда элемент стабилен и событие относится только к нему. Но в списке, который полностью перерисовывается, она делает жизненный цикл события зависимым от каждой вставки HTML. Такую зависимость лучше перенести на постоянный контейнер.'),
+    figure('/assets/editorial/2018/jquery-delegation-after-html.svg', 'Сравнение прямого обработчика на кнопке и делегированного обработчика на устойчивом контейнере после замены HTML', 'Прямой обработчик уходит вместе со старой кнопкой. Делегированный остаётся на контейнере и получает клик от новой дочерней кнопки.'),
+    heading('Прямая привязка и делегирование — это разные владельцы'),
+    paragraph('У .on() без селектора обработчик привязан к текущему набору элементов. Если передать селектор вторым аргументом, обработчик остаётся на выбранном предке и вызывается, когда событие всплывает от подходящего потомка. Документация jQuery называет эти варианты direct и delegated. Для нашего каталога владелец события должен быть не карточкой, а #products, если этот блок не заменяется целиком.'),
+    dataTable(
+      ['Подход', 'Где хранится обработчик', 'Что случится после .html()', 'Подходит для'],
+      [
+        ['Прямой $(".js-buy").on(...)', 'На найденных кнопках', 'Старые узлы удалены, новым кнопкам нужен новый bind', 'Стабильная одиночная кнопка или плагин, которому нужен именно элемент'],
+        ['Делегированный $root.on(..., ".js-buy", ...)', 'На постоянном $root', 'Новая кнопка под тем же корнем начинает работать сразу', 'Карточки, строки таблицы, пункты меню, которые заменяются'],
+        ['На document', 'На самом верхнем доступном узле', 'Технически может пережить почти любую замену', 'Только когда ближнего постоянного контейнера действительно нет'],
+      ],
+    ),
+    heading('Исправление на устойчивом контейнере'),
+    paragraph('Выберем ближайший узел, который существует до и после обновления списка. Здесь это #products. Перед назначением снимем только своё пространство имён: так повторная инициализация не будет плодить обработчики, а соседние click-события останутся на месте.'),
+    codeBlock(String.raw`
+(function ($) {
+  function addToCart(event) {
+    event.preventDefault();
+
+    var $link = $(this);
+    var productId = $link.data('product-id');
+
+    if (!productId) {
+      window.console.warn('У кнопки нет product-id');
+      return;
+    }
+
+    window.console.log('Добавляем товар ' + productId);
+  }
+
+  function mountProductList(root) {
+    var $root = $(root);
+
+    $root.off('.productList');
+    $root.on('click.productList', '.js-buy', addToCart);
+  }
+
+  window.mountProductList = mountProductList;
+}(jQuery));
+
+mountProductList('#products');
+`),
+    paragraph('Теперь серверный ответ может заменить внутренности #products, а обработчик остаётся на самом контейнере. Он увидит клик, который всплывёт от новой ссылки и совпадёт с селектором .js-buy. Если проект меняет и сам #products, этот код не сделает чудо: нужно вызвать mountProductList для нового контейнера или выбрать более внешний, но всё ещё локальный корень.'),
+    heading('Проверяем разметку и событие по отдельности'),
+    paragraph('В legacy-проекте легко перепутать три причины: Ajax вернул не ту разметку, селектор не совпал или событие не дошло до корня. Поэтому я бы проверял их раздельно. Сначала подменяю HTML статической строкой, затем запускаю программный click, и только после этого возвращаю реальный запрос. Так сетевой сбой не маскирует ошибку жизненного цикла DOM.'),
+    codeBlock(String.raw`
+var calls = 0;
+var $root = $('');
+
+$('body').append($root);
+
+$root.off('.demo');
+$root.on('click.demo', '.js-buy', function (event) {
+  event.preventDefault();
+  calls += 1;
+});
+
+$root.html('Купить другую');
+$root.find('.js-buy').trigger('click');
+
+window.console.assert(calls === 1, 'Делегированный click должен дойти до корня');
+$root.remove();
+`),
+    paragraph('Если проверка не проходит, сначала смотрим на корень: он существует в момент вызова .on(), внутри него действительно лежит новая кнопка, и её класс совпадает с селектором? Затем проверяем тип события. В документации jQuery есть важные исключения: делегированные обработчики не работают для SVG, а некоторые события не всплывают. Для таких случаев нельзя механически переносить click-шаблон.'),
+    heading('Почему document — не первая точка'),
+    paragraph('У document есть соблазнительное свойство: он почти всегда живёт дольше виджета. Но документация jQuery советует выбирать место как можно ближе к целевым элементам. На большой странице делегирование высокочастотных событий сверху заставляет jQuery сравнивать селекторы по длинному пути всплытия. Для click на небольшом участке разница может быть незаметна, но архитектурно всё равно лучше, когда каталог слушает каталог, а не весь сайт.'),
+    paragraph('Есть и практическая причина. Локальный корень показывает границу ответственности: код карточек не должен случайно перехватить похожую кнопку в модальном окне или в шапке. Селектор .js-buy становится понятным только в контексте #products.'),
+    heading('Отдельный риск: строка HTML — это не безопасные данные'),
+    paragraph('У .html() есть ещё один неприятный край. Документация jQuery предупреждает, что методы, принимающие HTML-строку, потенциально выполняют код из вставленных тегов или атрибутов. Поэтому в пример выше строка попала только как тестовая разметка, написанная в исходнике. Нельзя передавать в .html() необработанный параметр URL, текст из формы или поле API, если сервер не гарантирует его безопасное формирование.'),
+    heading('Порядок исправления'),
+    orderedList([
+      'Найти точный вызов .html() или другой код, который заменяет дочерние карточки.',
+      'Проверить, какой ближайший контейнер не заменяется при обновлении.',
+      'Снять со стабильного контейнера только события конкретного виджета по пространству имён.',
+      'Назначить делегированный обработчик с простым селектором потомка.',
+      'Подменить разметку тестовой строкой и вызвать click программно, чтобы отделить DOM-проблему от сети.',
+      'Вернуть реальный Ajax и отдельно проверить, что HTML приходит из доверенного источника и соответствует ожидаемому контракту.',
+    ]),
+    heading('Ограничения'),
+    bulletList([
+      'Делегирование не заменяет прямую привязку во всех случаях. Если нужен обработчик на самом элементе плагина или событие не всплывает, придётся выбрать другой контракт.',
+      'По документации jQuery делегированные обработчики не работают для SVG. Для интерактивных SVG нельзя рассчитывать на этот пример без отдельной проверки.',
+      'Слишком общий корень и тяжёлый селектор могут создать лишнюю работу при частых событиях. Выбираем ближайший живой контейнер и простую границу.',
+      'Починка click не решает вопрос повторной серверной операции. Контракт формы и запросов нужно проверять отдельно.',
+    ]),
+    heading('Итог'),
+    paragraph('После .html() новая кнопка — это новый DOM-узел без старого прямого обработчика. Делегирование решает ровно эту задачу, если обработчик живёт на устойчивом и близком контейнере. Когда мы называем владельца события и проверяем замену разметки отдельно от Ajax, исчезает и необходимость в случайных повторных bind.'),
+  ],
+  [jqueryHtml, jqueryOn, jqueryOff, jqueryData],
+);
+
+const fieldArticle = createRevision(
+  {
+    slug: 'editorial-2018-05-field-legacy-jquery',
+    title: 'jQuery. Как не отправить legacy-форму дважды',
+    categories: ['JavaScript', 'jQuery', 'Ajax'],
+    cover: '/assets/editorial/2018/jquery-ajax-form-contract.svg',
+    excerpt: 'Практический контракт Ajax-формы: один активный jqXHR, корректная сериализация, явные ветки успеха и ошибки, обязательное освобождение кнопки.',
+    readingMinutes: 10,
+  },
+  [
+    paragraph('Форма заказа в старом интерфейсе может отправиться дважды не только из-за двойного клика. Пользователь нажал Enter, скрипт повторно повесил submit, кнопка осталась активной до ответа или код решил «на всякий случай» повторить запрос. В браузере это выглядит как маленькая ошибка. На сервер могут уйти два одинаковых POST, а последствия уже зависят от предметной области.'),
+    paragraph('Главный вопрос здесь такой: как сделать Ajax-форму, которая допускает один активный запрос в текущем DOM-экземпляре, честно показывает ошибку и в любом исходе возвращает интерфейс в готовое состояние? Это не заменяет серверную защиту операции. Зато убирает повторную отправку, созданную именно фронтенд-кодом, и даёт понятную точку диагностики.'),
+    heading('Сначала определим, что именно отправляет форма'),
+    paragraph('Метод .serialize() строит URL-кодированную строку из успешных контролов формы. Практическое следствие простое: у поля должен быть name, выключенные поля не попадут в набор, неотмеченный checkbox тоже не попадёт, а файл через .serialize() не отправится. Поэтому перед переписыванием обработчика стоит открыть Network и сравнить фактические данные запроса с тем, что ожидает сервер.'),
+    paragraph('Я предпочитаю сериализовать сам <form>, а не объединять вручную все input на странице. Так не появляется дубль, когда в выборку по ошибке попали и форма, и её дочерние поля. Этот нюанс прямо описан в документации jQuery.'),
+    dataTable(
+      ['Состояние формы', 'Что хранится на форме', 'Что видит пользователь', 'Следующий переход'],
+      [
+        ['Готова', 'Ключ orderRequest отсутствует', 'Кнопка доступна', 'submit создаёт один jqXHR'],
+        ['Запрос идёт', 'В .data() лежит маркер или jqXHR', 'Кнопка отключена', 'Повторный submit сразу выходит'],
+        ['Успех', 'Сервер вернул ожидаемый ответ', 'Показываем подтверждённый результат', 'always освобождает интерфейс'],
+        ['Ошибка или timeout', 'jqXHR отклонён', 'Показываем понятную ошибку', 'always освобождает интерфейс'],
+      ],
+    ),
+    figure('/assets/editorial/2018/jquery-ajax-form-contract.svg', 'Состояния Ajax-формы: готова, запрос отправлен, успех или ошибка, затем обязательное освобождение интерфейса', 'Ветка успеха и ветка ошибки разные, а освобождение кнопки живёт в always и не зависит от параметров ответа.'),
+    heading('Минимальная разметка и граница обработчика'),
+    paragraph('Пусть форма существует на странице постоянно. Скрытый токен и поля уже выдаёт сервер; пример не придумывает их значение. Важно только, чтобы каждое отправляемое поле имело имя, а кнопка была внутри формы.'),
+    codeBlock(String.raw`
+
+ + + + +

+ +`), + paragraph('Клиентский замок я храню через .data() на самой форме. Это локально: на странице с двумя независимыми формами их состояния не смешаются. Для кнопки использую .prop("disabled", true), а не .attr, потому что disabled — динамическое свойство DOM; jQuery отдельно рекомендует .prop() для disabled и checked.'), + heading('Рабочий обработчик'), + paragraph('Пример написан для jQuery 3.x и использует done, fail и always у объекта jqXHR. Метод $.ajax() возвращает jqXHR с Promise-интерфейсом. В done мы разбираем ответ, в fail — транспортную ошибку, а в always выполняем действие, которому не нужны параметры ответа: освобождаем форму.'), + codeBlock(String.raw` +(function ($) { + var requestKey = 'orderRequest'; + + function showMessage($form, text, isError) { + $form.find('.js-order-message') + .toggleClass('is-error', isError) + .text(text); + } + + function unlock($form, $button) { + $form.removeData(requestKey); + $button.prop('disabled', false); + } + + function submitOrder(event) { + event.preventDefault(); + + var $form = $(this); + var $button = $form.find('[type="submit"]'); + + if ($form.data(requestKey)) { + return; + } + + $form.data(requestKey, true); + $button.prop('disabled', true); + showMessage($form, 'Отправляем…', false); + + var request; + + try { + request = $.ajax({ + url: $form.attr('action'), + type: $form.attr('method') || 'POST', + data: $form.serialize(), + dataType: 'json', + timeout: 10000 + }); + } catch (error) { + unlock($form, $button); + showMessage($form, 'Не удалось начать запрос', true); + return; + } + + $form.data(requestKey, request); + + request + .done(function (response) { + if (!response || response.ok !== true || typeof response.orderNumber === 'undefined') { + showMessage($form, 'Сервер не подтвердил оформление', true); + return; + } + + showMessage($form, 'Заказ принят: ' + response.orderNumber, false); + }) + .fail(function (xhr, status) { + var text = status === 'timeout' + ? 'Сервер не ответил вовремя. Проверьте статус заказа перед повтором.' + : 'Не удалось отправить форму. Попробуйте позже.'; + + showMessage($form, text, true); + }) + .always(function () { + unlock($form, $button); + }); + } + + $('#order-form') + .off('submit.orderForm') + .on('submit.orderForm', submitOrder); +}(jQuery)); +`), + paragraph('Маркер true записывается до старта Ajax. После успешного создания jqXHR он заменяется на сам объект запроса: это удобно для отладки в консоли, но в примере не используется для отмены. Если $.ajax() не удалось начать синхронно, блок catch снимает маркер и возвращает кнопку. В обычном сетевом отказе код пойдёт через fail, а always всё равно вернёт форму к начальному состоянию.'), + heading('Что именно проверяет этот код'), + paragraph('Первая защита — обработчик submit, а не только click на кнопке. Поэтому Enter в поле проходит тем же путём. Вторая защита — состояние на форме. Если тот же submit придёт, пока есть маркер, функция выходит без второго $.ajax(). Третья — переключение кнопки. Оно даёт пользователю видимый сигнал и уменьшает шанс случайного повторного действия, но не является единственным условием корректности.'), + codeBlock(String.raw` +// Временный диагностический крючок для staging: +var sent = 0; +var originalAjax = $.ajax; + +$.ajax = function () { + sent += 1; + return originalAjax.apply(this, arguments); +}; + +$('#order-form').trigger('submit'); +$('#order-form').trigger('submit'); + +window.console.assert(sent === 1, 'Форма не должна запускать второй Ajax до завершения первого'); +`), + paragraph('Такую подмену не надо оставлять в production. Она нужна, чтобы коротко воспроизвести контракт: два submit подряд должны создать один Ajax-вызов. Для реального теста вместо неё лучше замокать endpoint или проверять запросы в браузерном тесте. Но если счётчик сразу показывает два вызова, искать ошибку на сервере ещё рано.'), + heading('Почему success не равен завершению интерфейса'), + paragraph('Иногда старый код разблокирует кнопку только в callback успеха. Тогда при timeout, 500 или ошибке сети пользователь остаётся с выключенной формой и обновляет страницу. У jqXHR есть done, fail и always; документация jQuery рекомендует не анализировать аргументы в always, потому что при resolve и reject они различаются. Это как раз подходящее место для одинакового действия: убрать локальный маркер и вернуть кнопку.'), + paragraph('Успешный HTTP-ответ тоже не обязательно означает, что операция готова. В примере договор сервера требует response.ok === true и номер заказа. Если API проекта отвечает иначе, нужно описать именно его контракт: какие поля обязательны, где лежит текст ошибки, можно ли повторить запрос и когда результат считается подтверждённым. Не стоит считать успехом любой JSON только потому, что запрос завершился без сетевой ошибки.'), + heading('Последовательность внедрения'), + orderedList([ + 'Открыть текущую форму в браузере и зафиксировать фактический URL, метод, поля и ожидаемый ответ API.', + 'Проверить, что необходимые поля имеют name; отдельно решить, как отправляются файлы, потому что .serialize() их не включает.', + 'Перевести обработку на submit и снять только прежнее событие формы через уникальное пространство имён.', + 'Записать маркер до отправки, выключить кнопку через .prop() и создать один jqXHR.', + 'Разделить подтверждённый бизнес-ответ, ошибку транспорта и общее освобождение интерфейса.', + 'Проверить два submit подряд, timeout и ответ API с ошибкой; после каждого сценария форма должна либо показать результат, либо снова стать доступной.', + ]), + heading('Ограничения'), + bulletList([ + 'Клиентский маркер существует только в текущем DOM. Обновление страницы, второй браузер, ручный HTTP-запрос или повтор после timeout могут создать новый запрос. Критичная операция должна быть защищена на сервере по правилам конкретного домена.', + 'В примере нет загрузки файлов. Документация jQuery указывает, что file input не сериализуется через .serialize(); для него нужен отдельный согласованный транспорт.', + 'Не показываем номер заказа из любого произвольного ответа. Формат ok и orderNumber — пример контракта, который сервер должен подтвердить.', + 'Timeout — это отсутствие ответа за выбранный интервал, а не доказательство, что сервер ничего не сделал. Поэтому текст ошибки не обещает безопасный повтор, пока проект не определил проверку статуса операции.', + ]), + heading('Итог'), + paragraph('У legacy Ajax-формы должно быть немного состояний и ни одного скрытого перехода: формы нет в запросе, форма ждёт один jqXHR, затем показывает подтверждённый результат или ошибку и в любом случае освобождает интерфейс. Такой код не решает серверную идемпотентность, но перестаёт создавать собственные дубли и даёт читабельную точку для следующей диагностики.'), + ], + [jqueryAjax, jquerySerialize, jqueryProp, jqueryData, jqueryRemoveData, jqueryAlways], +); + +export const revisions = [practiceArticle, mechanismArticle, fieldArticle] + .map(({ bodyLength, ...revision }) => revision); + +if (process.argv[1]?.endsWith('/upgrade-2018-05.mjs')) { + if (process.argv.includes('--print-revisions')) { + process.stdout.write(JSON.stringify(revisions, null, 2) + '\n'); + } else { + process.stderr.write('Usage: node web/scripts/upgrade-2018-05.mjs --print-revisions\n'); + process.exitCode = 1; + } +}