Кофе && Код

Утренние советы, best practices и продвинутые техники за чашкой кофе.

HTML компонента из контроллера вместе с CSS и JS

HTML компонента из контроллера вместе с CSS и JS

Проблема/контекст

Экшен отдаёт HTML компонента, а его стили и скрипты остаются на сервере: шаблон вызвал Extension::load() или AddHeadScript(), но в AJAX-ответ это не попало. Ручная сборка списков ассетов на фронте быстро расходится с реальными зависимостями шаблона. В ядре для этого есть Bitrix\Main\Engine\Response\Component, но контракт ответа в документации не описан.

Решение с кодом

Response\Component — final-класс, наследник HtmlContent. Его конструктор: __construct($componentName, $componentTemplate = '', array $componentParams = [], array $additionalResponseParams = [], $dataKeys = []).

        <?php
declare(strict_types=1);

namespace Vendor\Catalog\Infrastructure\Controller;

use Bitrix\Main\Engine\Controller;
use Bitrix\Main\Engine\Response;

final class Slider extends Controller
{
    public function viewAction(int $sectionId): Response\Component
    {
        return $this->renderComponentAjax(
            'vendor:catalog.slider',
            '.default',
            ['SECTION_ID' => $sectionId],
            ['sectionId' => $sectionId], // -> data.additionalParams
            ['ITEMS', 'NAV_STRING'],     // срез arResult -> data.componentResult
        );
    }
}

    

Порядок работы задан в конструкторе HtmlContent: сначала ContentArea\Component::getHtml() выполняет $APPLICATION->IncludeComponent(...) внутри ob_start(), и только потом collectAssetsPathList() снимает списки с Asset::getInstance(). Поэтому в ответ попадает всё, что шаблон зарегистрировал во время рендера.

Итоговый JSON — обычная обёртка AjaxJson:

        {
  "status": "success",
  "data": {
    "html": "...",
    "assets": {"css": [], "js": [], "string": []},
    "additionalParams": {"sectionId": 12},
    "componentResult": {"ITEMS": [], "NAV_STRING": "..."}
  },
  "errors": []
}

    

Ключ ассетов называется string, а не strings. componentResult есть в ответе всегда (ContentArea\Component реализует DataSectionInterface), при пустом $dataKeys в нём []. $dataKeys же управляет шестым аргументом IncludeComponent — returnResult. Массив режет arResult через array_intersect_key, callable получает весь результат компонента. В renderComponentAjax() параметр объявлен как array, поэтому для callable объект создают напрямую:

        return new Response\Component(
    componentName: 'vendor:catalog.slider',
    componentTemplate: '.default',
    componentParams: ['SECTION_ID' => $sectionId],
    dataKeys: static fn(array $result): array => ['count' => count($result['ITEMS'])],
);

    

Признак для фронта — заголовок X-Process-Assets: assets, он ставится в конструкторе HtmlContent. Разбирает его buildAjaxPromiseToRestoreCsrf в js/main/core/core_ajax.js, то есть работает только через BX.ajax.runAction и BX.ajax.runComponentAction. Сценарий: строки вида <script type="extension/settings" сразу уходят в head, затем BX.load(css), BX.loadScript(js), затем остальные inline-строки, и только после этого промис резолвится.

        BX.ajax.runAction('vendor:catalog.api.slider.view', { data: { sectionId: 12 } })
    .then((response) => {
        // CSS и JS уже загружены
        BX.html(document.getElementById('slider'), response.data.html);
        console.log(response.data.componentResult.ITEMS);
    });

    

Уже подключённые файлы BX.load пропускает: isCssLoaded() / isScriptLoaded() сравнивают URL без ?timestamp и .min со списком из DOM и BX.setJSList() / BX.setCSSList(). При объединении CSS/JS сравнивается URL бандла. Компонент из рабочей области попадает в page_<hash>.js, а ответ приносит те же файлы как template_<hash>.js. Хэш совпадает, а URL разный, поэтому script.js шаблона выполнится повторно и должен это выдерживать. Inline-строки и теги extension/settings уходят в head при каждом запросе: дубли отсекаются только внутри одного ответа. Если дёрнуть тот же экшен голым fetch или BX.ajax, заголовок никто не обработает и ассеты придётся подключать вручную.

HtmlContent — более общий класс: он принимает любой ContentAreaInterface, то есть любой источник готового HTML, а секцию данных добавляет только тогда, когда объект дополнительно реализует DataSectionInterface. Так делают, например, в модуле sale — анонимный класс с одним методом getHtml(). Response\Component — частный случай для компонента: он собирает ContentArea\Component и передаёт его в родительский конструктор.

Итог

Response\Component возвращает HTML вместе с фактически использованными CSS, JS и inline-строками и помечает ответ заголовком X-Process-Assets. На фронте достаточно BX.ajax.runAction — ассеты подгрузятся до резолва промиса.

Collection::sortByColumn: сортировка массива строк по нескольким колонкам

Collection::sortByColumn: сортировка массива строк по нескольким колонкам

Проблема/контекст

Данные собраны из нескольких источников: остатки из внешнего API, цены из ORM, рейтинг из кеша. Отсортировать результат в SQL уже нельзя, и в коде появляется usort() с замыканием на десять строк и тремя <=>. В ядре для этого есть Bitrix\Main\Type\Collection. Класс старый, в новой документации его нет, и у sortByColumn() есть поведение, которое по сигнатуре не угадать.

Решение с кодом

        <?php
declare(strict_types=1);

use Bitrix\Main\Type\Collection;

$rows = [
    ['ID' => 7, 'STORE' => 'Склад Б', 'QUANTITY' => '15', 'PRICE' => 990.0],
    ['ID' => 3, 'STORE' => 'склад а', 'QUANTITY' => '9'],
    ['ID' => 5, 'STORE' => 'Склад А', 'QUANTITY' => '15', 'PRICE' => 1200.0],
];

Collection::sortByColumn(
    array: $rows,                               // массив $rows передаётся в метод по ссылке
    columns: [
        'QUANTITY' => [SORT_NUMERIC, SORT_DESC],
        'STORE' => SORT_ASC,
    ],
    callbacks: ['STORE' => 'mb_strtolower'],    // значение колонки проходит через callback перед сравнением
    defaultValueIfNotSetValue: 0,               // подставляется, если колонки в строке нет
    preserveKeys: true,                         // сохранить ключи массива
);

print_r($rows);

    

Короткая форма Collection::sortByColumn($rows, 'PRICE') равна ['PRICE' => SORT_ASC].

Внутри метод вынимает каждую колонку в отдельный массив и вызывает array_multisort(), передавая исходный массив последним аргументом. Отсюда четыре момента.

  1. Флаги сортировки те же, что у array_multisort(): SORT_NUMERIC, SORT_STRING, SORT_NATURAL, SORT_FLAG_CASE. Количество '15' и '9' без SORT_NUMERIC сравнится как строки.
  2. Массив сортируется по ссылке, метод ничего не возвращает. Без пятого аргумента числовые ключи перенумеровываются. Если ключами были ID элементов, они пропадут.
  3. При полном равенстве по всем колонкам array_multisort() переходит к следующему аргументу, то есть сравнивает строки целиком как массивы. Порядок получается детерминированным, но зависит от содержимого остальных полей. Если при равенстве важен исходный порядок, добавьте последней колонкой порядковый номер.
  4. Callback должен быть callable. Опечатка в имени функции даёт ArgumentOutOfRangeException('callbacks'). Один callback строкой применяется ко всем колонкам, массив вида колонка => callback действует выборочно.

В том же классе есть методы для вложенных массивов:

        $options = ['delivery' => ['pickup' => ['enabled' => true]]];

Collection::getByNestedKey($options, ['delivery', 'pickup', 'enabled']); // true
Collection::getByNestedKey($options, ['delivery', 'courier', 'enabled']); // null
Collection::setByNestedKey($options, ['delivery', 'courier', 'enabled'], false);

    

getByNestedKey() возвращает null для отсутствующего ключа, но на каждом шаге вызывает array_key_exists() для текущего значения. Если по пути встретился скаляр (['delivery' => 'off']), PHP 8 бросит TypeError. setByNestedKey() создаёт промежуточные массивы сам, а на пустом пути бросает ArgumentException.

Итог

sortByColumn() заменяет самописные компараторы для табличных данных. Указывайте SORT_NUMERIC для чисел в строках и пятый аргумент, когда ключи массива имеют значение.

Резерв корзины в Sale — не складской документ

Резерв корзины в Sale — не складской документ

Проблема/контекст

Резерв позиции заказа и складской резерв — разные сущности. \Bitrix\Catalog\StoreProductTable::QUANTITY_RESERVED показывает итоговое число по паре товар-склад, а снимается оно документом TYPE_UNDO_RESERVE. Строку резерва конкретной позиции корзины ведёт модуль sale — через \Bitrix\Sale\Reservation\BasketReservationService. Правка складского остатка мимо него оставит заказ и склад рассогласованными.

Решение с кодом

Сервис зарегистрирован в sale/.settings.php под ключом sale.basketReservation, зависимость BasketReservationHistoryService собирается в constructorParams. Есть и статический ярлык BasketReservationService::getInstance().

Данные лежат в b_sale_basket_reservation (BasketReservationTable): QUANTITY, DATE_RESERVE, DATE_RESERVE_END, BASKET_ID — обязательные, плюс STORE_ID и RESERVED_BY. Все три метода записи возвращают Bitrix\Main\Result:

        <?php
declare(strict_types=1);

use Bitrix\Main\DI\ServiceLocator;
use Bitrix\Main\Loader;
use Bitrix\Main\Type\DateTime;
use Bitrix\Sale\Reservation\BasketReservationService;

Loader::includeModule('sale');

/** @var BasketReservationService $service */
$service = ServiceLocator::getInstance()->get('sale.basketReservation');

$result = $service->add([
    'BASKET_ID' => $basketId,
    'STORE_ID' => $storeId,
    'QUANTITY' => 3.0,
    'DATE_RESERVE' => new DateTime(),
    'DATE_RESERVE_END' => (new DateTime())->add('7 days'),
]);

if ($result->isSuccess())
{
    $service->update((int)$result->getId(), ['QUANTITY' => 5.0]);
}

    

Ключевое отличие от прямой работы с таблицей — история. add() после успешной вставки вызывает addByReservation(), update(int $id, array $fields) — updateByReservation(), delete(int $id) — deleteByReservation(). История в BasketReservationHistoryTable хранит не снимки, а дельты: при изменении количества с 80 на 90 добавляется строка на 10. По этим дельтам и их датам считается, сколько позиция реально может списать со склада:

        <?php
declare(strict_types=1);

// $ret[$productId][$storeId] => доступное количество
$byOrder = $service->getAvailableCountForOrder($orderId);

$available = $service->getAvailableCountForBasketItem($basketId, $storeId); // float

    

Логика такая: если первым зарезервировали 80 из 100, а вторым — 40, то первая сделка спишет 80, вторая — 20. Порядок резервирования учитывается.

В прикладном коде заказа отдельный вызов сервиса обычно не нужен: ReserveQuantity::addInternal(), updateInternal() и ReserveQuantityCollection::deleteInternal() уже ходят в sale.basketReservation, поэтому $basketItem->getReserveQuantityCollection() пишет историю автоматически. Сервис нужен для диагностики, миграций и внешних интеграций.

Условие резерва и срок хранения приходят из ReservationSettingsService (ключ sale.reservation.settings) — метод get() читает опции sale/product_reserve_condition и sale/product_reserve_clear_period, собирает ReservationSettings и рассылает ReservationSettingsBuildEvent с именем OnReservationSettingsBuild. В обработчике можно вызвать $event->getSettings()->setReserveCondition(ReserveCondition::ON_CREATE) и включить автоматический резерв, не меняя настройки модуля. Допустимые значения — ON_CREATE, ON_PAY, ON_FULL_PAY, ON_ALLOW_DELIVERY, ON_SHIP; setReserveCondition() валидирует их и бросает SystemException.

Итог

Резерв позиции — это строка b_sale_basket_reservation плюс история дельт, а не складской документ. Меняйте её через sale.basketReservation, а условие резервирования переопределяйте событием OnReservationSettingsBuild.

PropertyFeature вместо ручного списка свойств каталога

PropertyFeature вместо ручного списка свойств каталога

Проблема/контекст

Контент-менеджер отмечает в карточке свойства галки «Показывать в списке» и «Показывать на детальной», а разработчик в своём компоненте дублирует те же коды в параметре PROPERTY_CODE. Списки расходятся при первом же изменении настроек. Эти галки хранятся не в свойстве, а в отдельной таблице b_iblock_property_feature, и у неё есть публичное API — Bitrix\Iblock\Model\PropertyFeature.

Решение с кодом

Движок фич включается опцией «Использовать параметры свойств в компонентах и формах» property_features_enabled модуля iblock (значение по умолчанию — Y). Проверка — PropertyFeature::isEnabledFeatures(): bool.

        <?php
declare(strict_types=1);

use Bitrix\Iblock\Model\PropertyFeature;
use Bitrix\Main\Loader;

Loader::includeModule('iblock');

if (PropertyFeature::isEnabledFeatures())
{
    // ['COLOR', 'MATERIAL'] или null
    $listCodes = PropertyFeature::getListPageShowPropertyCodes($iblockId, ['CODE' => 'Y']);
    $detailCodes = PropertyFeature::getDetailPageShowPropertyCodes($iblockId, ['CODE' => 'Y']);
}


    

Детали, которые важны на практике:

  • сигнатура — getListPageShowPropertyCodes($iblockId, array $parameters = []): ?array; второй аргумент понимает единственный ключ CODE в верхнем регистре;
  • без ['CODE' => 'Y'] возвращаются ID свойств (строками), а не символьные коды. С CODE => 'Y' вернётся CODE, а при пустом CODE — ID;
  • возвращается null, если движок выключен, $iblockId <= 0 или подходящих свойств нет. Пустого массива не будет — обрабатывайте null явно;
  • выборка фильтрует PROPERTY.ACTIVE = 'Y' и IS_ENABLED = 'Y', сортировка — по PROPERTY.SORT, затем PROPERTY.ID.

Именно так делают штатные компоненты. bitrix:catalog.section наследует Bitrix\Iblock\Component\ElementList: метод initIblockPropertyFeatures() вызывает loadDisplayPropertyCodes(), тот берёт getListPageShowPropertyCodes($iblockId, ['CODE' => 'Y']) для основного инфоблока и для инфоблока торговых предложений и кладёт результат в storage['IBLOCK_PARAMS'][$iblockId]['PROPERTY_CODE'] и OFFERS_PROPERTY_CODE. Точка входа — Base::processResultData(). bitrix:catalog.element через Bitrix\Iblock\Component\Element делает то же самое, но вызывает устаревший алиас getDetailPageShowProperties().

Хранилище — ORM-класс Bitrix\Iblock\PropertyFeatureTable (таблица b_iblock_property_feature, поля PROPERTY_ID, MODULE_ID, FEATURE_ID, IS_ENABLED). Писать напрямую не нужно: есть addFeatures(), updateFeatures(), setFeatures(), все возвращают Bitrix\Main\ORM\Data\Result. CIBlockProperty::Update() сам вызывает setFeatures(), если передан ключ FEATURES.

Свой тип фичи регистрируется обработчиком события модуля iblock с полным именем Bitrix\Iblock\Model\PropertyFeature::OnPropertyFeatureBuildList. Обработчик получает параметры property и description и возвращает EventResult(EventResult::SUCCESS, [...]) с элементами MODULE_ID, FEATURE_ID, FEATURE_NAME. Эталон — Bitrix\Catalog\Product\PropertyCatalogFeature::handlerPropertyFeatureBuildList(), добавляющий IN_BASKET и OFFER_TREE.

Итог

Набор отображаемых свойств задаётся в карточке свойства и лежит в b_iblock_property_feature. Читайте его через PropertyFeature::getListPageShowPropertyCodes() / getDetailPageShowPropertyCodes() с ['CODE' => 'Y'] и проверкой на null — тогда кастомный компонент покажет ровно то же, что catalog.section.

Text\Emoji: 4-байтовые символы в колонках utf8

Text\Emoji: 4-байтовые символы в колонках utf8

Проблема/контекст

Колонка в кодировке utf8 (utf8mb3) в MySQL хранит максимум три байта на символ. Эмодзи и часть CJK-расширений занимают четыре байта, поэтому запись обрывается на первом таком символе и остаток текста теряется. Переводить рабочую базу в utf8mb4 ради одного поля дорого, и ядро идёт другим путём — классом Bitrix\Main\Text\Emoji.

Решение с кодом

Emoji::encode() прогоняет строку через preg_replace_callback и заменяет каждую четырёхбайтовую последовательность на ":" . bin2hex($match) . ":". Кодируются байты UTF-8, а не кодовая точка: 😊 (U+1F60A) даёт :f09f988a: — 10 символов вместо 4 байт. Паттерн покрывает плоскости 1–16 (\xF0[\x90-\xBF], [\xF1-\xF3], \xF4[\x80-\x8F]), трёхбайтовые символы не затрагиваются.

Emoji::decode() работает по собственной регулярке /:([A-F0-9]{8}):/isu и возвращает подстроку без изменений, если hex2bin() не дал валидную четырёхбайтовую последовательность. Обратная сторона: строка :f09f988a:, набранная пользователем руками, при чтении станет эмодзи.

Ключевая деталь модификаторов: getSaveModificator() и getFetchModificator() ничего не преобразуют. Каждый возвращает массив колбэков — [[Emoji::class, 'encode']] и [[Emoji::class, 'decode']]. В параметр поля кладут именно фабрику: Field::getSaveDataModifiers() вызывает её через call_user_func() и бросает SystemException, если вернулся не массив. Так объявлены поля в Bitrix\Blog\PostTable, Bitrix\Vote\QuestionTable, моделях im и landing:

        'TITLE' => [
    'data_type' => 'string',
    'save_data_modification' => [\Bitrix\Main\Text\Emoji::class, 'getSaveModificator'],
    'fetch_data_modification' => [\Bitrix\Main\Text\Emoji::class, 'getFetchModificator'],
],

    

В объектной карте полей удобнее добавлять колбэки напрямую — они проходят проверку is_callable без фабрики:

        <?php
declare(strict_types=1);

namespace Vendor\Module\Model;

use Bitrix\Main\ORM\Data\DataManager;
use Bitrix\Main\ORM\Fields\IntegerField;
use Bitrix\Main\ORM\Fields\StringField;
use Bitrix\Main\ORM\Fields\TextField;
use Bitrix\Main\Text\Emoji;

final class CommentTable extends DataManager
{
    public static function getTableName(): string
    {
        return 'vendor_module_comment';
    }

    public static function getMap(): array
    {
        return [
            (new IntegerField('ID'))->configurePrimary()->configureAutocomplete(),
            (new StringField('TITLE'))
                ->addSaveDataModifier([Emoji::class, 'encode'])
                ->addFetchDataModifier([Emoji::class, 'decode']),
            (new TextField('MESSAGE'))
                ->addSaveDataModifier([Emoji::class, 'encode'])
                ->addFetchDataModifier([Emoji::class, 'decode']),
        ];
    }
}

    

Save-модификаторы применяются в DataManager в add(), update() и их multi-вариантах через Field::modifyValueBeforeSave(). Fetch-модификаторы собирает Query::isFetchModificationRequired() и применяет к алиасам выборки, то есть только к полям, попавшим в select.

Границы механизма:

  • фильтр не модифицируется, поиск по значению с эмодзи требует ручного вызова:
        CommentTable::getList([
    'filter' => ['=TITLE' => Emoji::encode($userInput)],
]);

    
  • закодированное значение длиннее исходного, для varchar закладывайте запас;
  • сырые запросы через Connection модификаторы не проходят — вызывайте Emoji::encode()/decode() сами;
  • флаг колонки читается через Application::getConnection()->isUtf8mb4($table, $column) из секции utf8mb4 в connections. Компоненты соцсети кодируют текст только при false, но ORM-модификаторы срабатывают всегда, независимо от этой настройки.

Итог

Text\Emoji держит четырёхбайтовые символы в трёхбайтовой колонке ценой десяти ASCII-символов на эмодзи. Подключайте модификаторы в карте полей, а фильтры и прямые SQL-запросы кодируйте вручную.

Парсер телефонов и страна по умолчанию в #[Phone]

Парсер телефонов и страна по умолчанию в #[Phone]

Проблема/контекст

Атрибут #[Phone] из Bitrix\Main\Validation\Rule выглядит как проверка формата строки. На деле он собирает единственный валидатор PhoneValidator, а тот вызывает Parser::getInstance()->parse($value)->isValid() — без второго аргумента. Внутри parse() пустая страна заменяется на Parser::getDefaultCountry(), поэтому один и тот же номер без + на двух порталах даст разный результат валидации.

Решение с кодом

Порядок источников в Parser::getDefaultCountry():

  1. Option::get('main', 'phone_number_default_country') — в опции хранится ID страны, а не двухбуквенный код. Значение задаётся в настройках главного модуля полем «Страна по умолчанию».
  2. Если опция пуста, вызывается detectCountry(): сначала REG_COUNTRY модуля bitrix24, затем портальная зона из списка br, cn, de, in, ru, ua, by, kz, fr, pl, uz, затем Context::getCurrent()->getLanguage() из списка br, cn, de, in, ru, ua, by, kz, fr, pl, и только в конце GeoIp\Manager::getCountryCode().
  3. Найденный код конвертируется в ID и записывается в опцию — первый же вызов фиксирует страну на всё время жизни портала.
  4. Наружу возвращается GetCountryCodeById($id), при неудаче — пустая строка.

Пустая строка означает, что MetadataProvider::getCountryMetadata('') вернёт false, и parse() отдаст невалидный PhoneNumber для любого номера без +. Номер с + разбирается по коду страны из самой строки, и $defaultCountry игнорируется — это единственный формат, устойчивый к настройкам портала.

В коде модуля страну задавайте явно вторым аргументом:

        <?php declare(strict_types=1);

use Bitrix\Main\PhoneNumber\Format;
use Bitrix\Main\PhoneNumber\Parser;

$number = Parser::getInstance()->parse('8 (916) 123-45-67', 'RU');

$number->isValid();            // true
$number->getCountry();         // RU
$number->getCountryCode();     // 7
$number->format(Format::E164); // +79161234567

    

Сигнатура — PhoneNumber::format($formatType = '', $forceNationalPrefix = false). Константы класса Bitrix\Main\PhoneNumber\Format: E164 (значение 'E.164'), INTERNATIONAL, NATIONAL. При пустом $formatType вызывается Formatter::formatOriginal() — он сохраняет исходное написание и откатывается к сырой строке, если форматирование изменило состав цифр. Для невалидного номера format() всегда возвращает getRawNumber(), поэтому isValid() проверяем до форматирования, а не после.

В базе храните Format::E164 — это '+' . countryCode . nationalNumber без разделителей, единственный вид, пригодный для поиска дублей и сравнения:

        <?php declare(strict_types=1);

namespace Vendor\Module\Application\Service;

use Bitrix\Main\PhoneNumber\Format;
use Bitrix\Main\PhoneNumber\Parser;

final class PhoneNormalizer
{
    public function __construct(private readonly string $defaultCountry = 'RU') {}

    public function normalize(string $raw): ?string
    {
        $number = Parser::getInstance()->parse($raw, $this->defaultCountry);

        return $number->isValid() ? $number->format(Format::E164) : null;
    }
}

    

Если валидация должна быть привязана к стране, напишите свой валидатор — интерфейс состоит из одного метода:

        <?php declare(strict_types=1);

namespace Vendor\Module\Validation\Validator;

use Bitrix\Main\Localization\LocalizableMessage;
use Bitrix\Main\PhoneNumber\Parser;
use Bitrix\Main\Validation\ValidationError;
use Bitrix\Main\Validation\ValidationResult;
use Bitrix\Main\Validation\Validator\ValidatorInterface;

final class CountryPhoneValidator implements ValidatorInterface
{
    public function __construct(private readonly string $country) {}

    public function validate(mixed $value): ValidationResult
    {
        $result = new ValidationResult();

        if (!is_string($value) || !Parser::getInstance()->parse($value, $this->country)->isValid())
        {
            $result->addError(new ValidationError(
                new LocalizableMessage('MAIN_VALIDATION_PHONE_INVALID'),
                failedValidator: $this,
            ));
        }

        return $result;
    }
}

    

Подключается он через собственный атрибут, унаследованный от AbstractPropertyValidationAttribute, с возвратом валидатора из getValidators().

Итог

#[Phone] проверяет номер относительно страны портала, а не относительно вашей предметной области, и эта страна может прийти из зоны Битрикс24, языка или GeoIP. Задавайте страну явно в Parser::parse() и храните результат в Format::E164.

Принудительный logout или обновление сессии через UserAuthActionTable

Принудительный logout или обновление сессии через UserAuthActionTable

Проблема/контекст

После смены пароля, блокировки пользователя или отзыва app-password нельзя «убить» чужую сессию прямым DELETE из b_user_session или изменить её: ядро хранит несколько типов авторизации (браузер, remember-me, REST с APPLICATION_ID), и рассинхрон легко оставит активный доступ.

Решение с кодом

Разлогинить пользователя везде (браузер и все приложения) — без APPLICATION_ID:

        <?php
declare(strict_types=1);

use Bitrix\Main\UserAuthActionTable;

// После компрометации аккаунта или смены пароля админом
UserAuthActionTable::addLogoutAction($userId);

    

Отозвать только один REST-клиент (как при удалении app-password в ApplicationPasswordTable::onDelete):

        <?php
declare(strict_types=1);

use Bitrix\Main\UserAuthActionTable;

// Только сессии с тем же APPLICATION_ID в Context
UserAuthActionTable::addLogoutAction($userId, 'my.module.api');

    

Обновить данные в сессии без выхода — например, после смены групп:

        <?php
declare(strict_types=1);

use Bitrix\Main\Type\DateTime;
use Bitrix\Main\UserAuthActionTable;

// Сейчас: пересчитать права в $_SESSION
UserAuthActionTable::addUpdateAction($userId);

// Отложенно: когда наступит дата активации группы
UserAuthActionTable::addUpdateAction($userId, new DateTime('2026-06-01 00:00:00'));

    

Важно:

  • срабатывание не мгновенное: запись обрабатывается на следующем запросе пользователя (после include.php ваш код уже не вызовет logout в том же хите);
  • если задан APPLICATION_ID, браузерная сессия запись пропустит (continue при несовпадении id);
  • getList() в проверке кешируется на 3600 секунд — в редких случаях logout может задержаться до истечения кеша ORM-запроса;
  • при смене пароля самим пользователем ядро ставит AUTH_ACTION_SKIP_LOGOUT, чтобы не выбрасывать его сразу после сохранения.

Просроченные записи чистит агент CUser::AuthActionsCleanUpAgent() (старше суток).

Итог

UserAuthActionTable — отложенная команда ядру: «на следующем хите разлогинь или обнови сессию». Для полного отзыва доступа вызывайте addLogoutAction($userId), для одного API-клиента добавляйте APPLICATION_ID и учитывайте кеш выборки при срочном отзыве.

Приватные поля ORM: select и filter ведут себя по-разному

Приватные поля ORM: select и filter ведут себя по-разному

Проблема/контекст

В D7 ORM поле можно пометить как приватное через 'private' => true или configurePrivate(). Такие поля скрыты от обычных запросов: ядро считает их служебными или чувствительными. Классический пример — PASSWORD в Bitrix\Main\UserTable, где хеш пароля не должен попадать в типовые выборки списков и отчётов.

Разработчик часто ожидает, что Query::enablePrivateFields() снимает все ограничения. Это не так: перед сборкой SQL ядро вызывает checkForPrivateFields(), и private-поля всегда запрещены в filter. В select и runtime они доступны только после явного разрешения. Тот же запрет распространяется на ExpressionField, если в его buildFrom участвует private-колонка — метод Query::isFieldPrivate() обходит цепочки выражения.

Решение с кодом

Попытка выбрать PASSWORD без флага завершится SystemException с текстом про enablePrivateFields(). Фильтрация по private-полю упадёт в любом случае — даже после enablePrivateFields().

        <?php
declare(strict_types=1);

use Bitrix\Main\UserTable;

// Ошибка: Private field ... PASSWORD is restricted in query
UserTable::query()
    ->setSelect(['ID', 'PASSWORD'])
    ->where('ACTIVE', 'Y')
    ->fetchAll();

    

Для чтения private-полей передайте 'private_fields' => true в getList() или вызовите enablePrivateFields() у query. Это удобно в админских скриптах и сервисах миграции, где доступ к служебным колонкам действительно нужен.

        <?php
declare(strict_types=1);

use Bitrix\Main\UserTable;

$rows = UserTable::getList([
    'select' => ['ID', 'LOGIN', 'PASSWORD'],
    'filter' => ['=ACTIVE' => 'Y'],
    'private_fields' => true,
    'limit' => 10,
])->fetchAll();

    

Фильтр по private-полю ядро не пропустит — в checkForPrivateFields() проверка filter_chains выполняется до проверки флага private_fields_on:

        <?php
declare(strict_types=1);

use Bitrix\Main\UserTable;

// Ошибка: Private field ... PASSWORD is restricted in filter
UserTable::query()
    ->enablePrivateFields()
    ->setSelect(['ID'])
    ->where('PASSWORD', 'some_hash')
    ->fetchAll();

    

В своих DataManager помечайте служебные колонки явно и не используйте их в публичных API:

        (new StringField('INTERNAL_TOKEN'))
    ->configurePrivate(),

    

Если нужна выборка «по секретному значению», добавьте отдельный публичный метод сервиса с параметризованным SQL или whitelist-фильтром по не-private полю. Не расширяйте filter ORM и не снимайте configurePrivate() ради одного запроса — это откроет поле для всех последующих вызовов getList() без контроля.

Итог

configurePrivate() защищает поле от случайного попадания в запросы и API. enablePrivateFields() и 'private_fields' => true разрешают только select и runtime; filter для private-полей в ORM закрыт намеренно и без обходного пути в query builder. Для поиска по чувствительным данным проектируйте отдельный контур, а не пытайесь «обойти» ограничение через WHERE.

Application::addBackgroundJob — что происходит после ответа клиенту

Application::addBackgroundJob — что происходит после ответа клиенту

Проблема

Метод \Bitrix\Main\Application::addBackgroundJob() часто используют по памяти: передают callback с use и надеются, что «задача выполнится где-то в фоне». На практике у метода строгая сигнатура и несколько неочевидных побочных эффектов из Application::terminate(), о которых молчит PHPDoc. Разберём реальное поведение по коду ядра main/lib/application.php.

Решение

Сигнатура из ядра (main/lib/application.php):

        public function addBackgroundJob(
    callable $job,
    array $args = [],
    $priority = self::JOB_PRIORITY_NORMAL
);

    

Аргументы задачи — это второй параметр $args, а не замыкание с use. Оба варианта работают (внутри вызов идёт через call_user_func_array), но штатный путь — именно $args:

        \Bitrix\Main\Application::getInstance()->addBackgroundJob(
    [\Vendor\Module\Application\Service\Notifier::class, 'sendWelcome'],
    [$userId, $locale],
    \Bitrix\Main\Application::JOB_PRIORITY_NORMAL,
);

    

Доступные приоритеты: JOB_PRIORITY_NORMAL = 100, JOB_PRIORITY_LOW = 50. Очередь построена на SplPriorityQueue — большее число выполняется раньше.

Что именно происходит после отправки ответа, видно в terminate():

        // псевдокод из Application::terminate()
\CMain::RunFinalActionsInternal();
session_write_close();              // 1. Сессия закрыта
$pool = $this->getConnectionPool();
$pool->useMasterOnly(true);         // 2. Только мастер БД
$this->runBackgroundJobs();         // 3. Запуск джобов
$pool->useMasterOnly(false);

    

К моменту запуска джобов HttpResponse::send() уже вызвал fastcgi_finish_request() — клиент получил ответ и закрыл соединение. Отсюда прикладные следствия:

  1. Писать в $_SESSION бесполезно — сессия закрыта. Нужна сессия — используйте отдельное хранилище или перенесите логику в Messenger.
  2. Все SQL-запросы идут в мастер, реплики не используются — держите задачи короткими.
  3. Исключения внутри джобов ловятся и логируются через ExceptionHandler::writeToLog(), но последнее исключение после прогона всей очереди пробрасывается наверх. На ответ клиента это не повлияет, но зафиксируется в логах php-fpm и может оборвать воркер.
  4. Задачи могут добавлять новые задачи — внешний while ($this->backgroundJobs->valid()) дренирует очередь до пустоты. Следите за идемпотентностью, чтобы не получить бесконечный цикл.
  5. Нет гарантии доставки: падение процесса (OOM, таймаут) — задача теряется. Для надёжной обработки с ретраями используйте Messenger.

Итог

addBackgroundJob — это не «очередь», а хвост текущего запроса с уже закрытой сессией и мастер-соединением. Подходит для короткой работы в несколько сотен миллисекунд: метрики, уведомления, отложенные логи. Всё, что требует гарантий, — только через Bitrix\Main\Messenger.

Почему нельзя фильтровать по CryptoField и как это обойти

Почему нельзя фильтровать по CryptoField и как это обойти

Проблема/контекст

CryptoField шифрует данные при записи и расшифровывает при чтении. Однако каждый вызов encrypt() в Bitrix\Main\Security\Cipher генерирует случайный вектор инициализации (IV). Одно и то же значение при каждом шифровании превращается в разный шифротекст. Это значит, что фильтрация через getList() по зашифрованному полю не вернет результатов: SQL-запрос сравнит открытый текст из фильтра с шифротекстом в базе и ничего не найдет.

На практике проблема возникает, как только нужно найти запись по API-токену, ключу доступа или другому зашифрованному идентификатору — задача, которая появляется в любой интеграции.

Решение с кодом

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

        <?php
declare(strict_types=1);

namespace Vendor\Integration\Model;

use Bitrix\Main\ORM\Data\DataManager;
use Bitrix\Main\ORM\Fields;
use Bitrix\Main\Security\Random;

final class ApiTokenTable extends DataManager
{
    public static function getTableName(): string
    {
        return 'vendor_api_tokens';
    }

    public static function getMap(): array
    {
        return [
            new Fields\IntegerField('ID', [
                'primary' => true,
                'autocomplete' => true,
            ]),
            new Fields\IntegerField('USER_ID', [
                'required' => true,
            ]),
            // Зашифрованный токен — только для хранения и чтения
            new Fields\CryptoField('TOKEN', [
                'crypto_enabled' => static::cryptoEnabled('TOKEN'),
            ]),
            // SHA-256 хеш токена — для поиска по индексу
            new Fields\StringField('TOKEN_HASH', [
                'size' => 64,
            ]),
        ];
    }

    // Создает запись с новым токеном
    public static function issueToken(int $userId): array
    {
        $token = Random::getString(32);

        $result = static::add([
            'USER_ID' => $userId,
            'TOKEN' => $token,
            'TOKEN_HASH' => hash('sha256', $token),
        ]);

        return ['id' => $result->getId(), 'token' => $token];
    }

    // Находит запись по открытому токену через хеш
    public static function findByToken(string $token): ?array
    {
        return static::getRow([
            'filter' => ['=TOKEN_HASH' => hash('sha256', $token)],
        ]);
    }
}

    

Схема: при создании записи токен шифруется CryptoField и сохраняется в TOKEN, а его SHA-256 хеш записывается в TOKEN_HASH. При входящем запросе вычисляется хеш и выполняется поиск по индексированной колонке. Исходный токен восстанавливается из зашифрованного поля автоматически при чтении.

Размер колонки TOKEN должен учитывать увеличение данных при шифровании. Для 32-байтного токена в режиме CTR минимальный размер: (32 + 16 + 32) * 1.5 ≈ 120 байт. Используйте VARCHAR(255). Колонка TOKEN_HASH фиксированная — CHAR(64) с уникальным индексом.

Еще одна деталь, которую стоит учитывать: если шифрование завершится ошибкой — например, ключ не настроен в .settings.php — метод encrypt() возвращает null, а не исходное значение. Данные молча теряются. Поэтому всегда проверяйте доступность шифрования через CryptoField::cryptoAvailable() перед включением криптополей, как это делается в ядре — например, в UserPhoneAuthTable для OTP-секретов.

Паттерн похож на хранение паролей, но с важным отличием: здесь исходное значение нужно восстановить, поэтому вместо необратимого bcrypt используется обратимое шифрование AES-256-CTR через CryptoField, а хеш служит только для поиска.

Итог

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

Отложенный ресайз облачных файлов в CFile::ResizeImageGet

Отложенный ресайз облачных файлов в CFile::ResizeImageGet

Проблема/контекст

У CFile::ResizeImageGet() есть шестой параметр $bImmediate, но по коду ядра он не включает «отложенный ресайз» сам по себе. В main/classes/general/file.php метод только передает этот флаг в событие OnBeforeResizeImage, а реальная delayed-логика подключается обработчиком модуля clouds.

Это важно, потому что для локальных файлов ресайз остается синхронным, а для файлов с HANDLER_ID из облачного хранилища поведение меняется. В CCloudStorage::OnBeforeResizeImage() ядро проверяет опцию clouds.delayed_resize и при $bImmediate = false не строит миниатюру сразу, а ставит задачу в таблицу b_clouds_file_resize через ResizeImageFileDelay(). Затем CCloudStorage::OnBeforeProlog() перехватывает запрос к resize_cache, вызывает ResizeImageFileCheck() и уже после генерации перенаправляет на готовый файл в бакете.

Решение с кодом

Практическое правило простое: delayed-режим подходит для публичных списков, где допустим отдельный HTTP-запрос на первую генерацию картинки. Для AJAX-ответов, модальных превью, административных форм и мест, где URL должен вести на уже существующий файл, ставьте $bImmediate = true. Сам Bitrix следует этому же правилу: в Bitrix\Main\UI\FileInputUnclouder::exec() и шаблоне main.file.input превью строится через ResizeImageGet(..., true).

        <?php
declare(strict_types=1);

/**
 * Для cloud-файлов решаем, можно ли отложить генерацию.
 */
function getPreviewImage(array $file, int $width, int $height, bool $needImmediate): array
{
    $result = \CFile::ResizeImageGet(
        $file,
        ['width' => $width, 'height' => $height],
        bInitSizes: true,   // вернуть реальные размеры результата
        bImmediate: $needImmediate
    );

    return is_array($result) ? $result : [];
}

$file = \CFile::GetFileArray($pictureId);
$isCloudFile = (int)($file['HANDLER_ID'] ?? 0) > 0;

// В списке товаров допускаем delayed-генерацию только для cloud-файлов.
$listImage = getPreviewImage($file, 320, 320, false);

// В AJAX-превью файл нужен сразу, иначе можно получить URL на еще не созданный объект.
$modalImage = getPreviewImage($file, 1200, 1200, $isCloudFile);

    

Если вы вызываете ResizeImageGet() в JSON-ответе или сразу вставляете src в интерфейс, не рассчитывайте, что delayed-режим успеет создать файл к этому же ответу. Документация показывает только прямой CFile::ResizeImage() после SaveFile(), но не объясняет этот разрыв между локальным и cloud-сценарием. Поэтому проверяйте HANDLER_ID и осознанно выбирайте режим генерации, а не передавайте шестой параметр «на всякий случай».

Есть и еще один нюанс, который виден только в исходниках clouds. Метод ResizeImageFileDelay() не ставит задачу без необходимости: если Rectangle::resize() показывает, что новое изображение не требуется и нет фильтров или watermark, delayed-сценарий не включается. А если задача уже падала, ядро пытается перезапустить ее только спустя пять минут по TIMESTAMP_X. Это означает, что delayed-режим полезен именно как фоновая оптимизация выдачи, а не как гарантия мгновенного результата при повторном запросе.

Итог

$bImmediate имеет практический смысл только в связке с облачным хранилищем и clouds.delayed_resize. Для интерактивных превью включайте немедленную генерацию, для публичных страниц можно оставлять delayed-режим.

Как удаляются связи OneToMany и ManyToMany в D7 ORM

Как удаляются связи OneToMany и ManyToMany в D7 ORM

Проблема/контекст

В D7-связях удаление часто понимают слишком упрощенно: если сущности связаны, значит ядро само «разрулит» каскад. Но в исходниках ORM поведение у OneToMany и ManyToMany принципиально разное, и ошибка в ожиданиях быстро приводит либо к лишнему удалению, либо к зависшим данным.

Ключевая точка здесь Bitrix\Main\ORM\Objectify\EntityObject::delete(). Для OneToMany метод смотрит на getCascadeDeletePolicy() и либо удаляет дочерние объекты, либо снимает ссылку через sysRemoveAllFromCollection(). Для ManyToMany логика иная: при удалении объекта ядро всегда очищает таблицу-посредник, а сами связанные сущности не удаляет. Это напрямую следует из связки EntityObject::delete(), sysRemoveAllFromCollection() и sysSaveRelations().

Решение с кодом

Если дочерняя запись принадлежит только одному владельцу, используйте OneToMany и явно задавайте политику удаления:

        <?php
use Bitrix\Main\ORM\Fields\Relations\CascadePolicy;
use Bitrix\Main\ORM\Fields\Relations\OneToMany;

final class OrderTable extends \Bitrix\Main\ORM\Data\DataManager
{
    public static function getMap(): array
    {
        return [
            // При удалении заказа удалятся и его позиции
            (new OneToMany('ITEMS', OrderItemTable::class, 'ORDER'))
                ->configureCascadeDeletePolicy(CascadePolicy::FOLLOW),
        ];
    }
}

    

Если FOLLOW не указан, у OneToMany по умолчанию работает SET_NULL: ядро не удаляет дочерние строки, а отвязывает их от родителя. Это удобно для журналов, уведомлений и других записей, которые могут жить дольше основной сущности.

Для общих справочников и тегов используйте ManyToMany, но помните реальное поведение удаления:

        <?php
use Bitrix\Main\ORM\Fields\Relations\ManyToMany;

final class ArticleTable extends \Bitrix\Main\ORM\Data\DataManager
{
    public static function getMap(): array
    {
        return [
            // При удалении статьи ядро очистит только b_article_tag
            (new ManyToMany('TAGS', TagTable::class))
                ->configureTableName('b_article_tag'),
        ];
    }
}

    

После $article->delete() будут удалены строки из b_article_tag, потому что sysSaveRelations() удаляет записи посредника через data class медиаторной сущности. Сами теги останутся. Поэтому ManyToMany подходит там, где связь должна исчезнуть, а партнерская сущность продолжает использоваться в системе. Если же нужно удалять и «осиротевшие» записи, не полагайтесь на ManyToMany: описывайте таблицу связи явно или запускайте отдельный сервис очистки после удаления основной сущности.

Итог

Для OneToMany в D7 важно выбирать политику FOLLOW или SET_NULL осознанно. Для ManyToMany безопасно рассчитывать только на очистку таблицы связей, а не на удаление связанных объектов.

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

AI Домовой История

на связи

пишет…
Нет истории чатов
AI Домовой

Нужна авторизация

Войдите, чтобы задавать вопросы AI Домовому.

Войти