Files
Portal-Chart-Builder/docs/bitrix-integration.md
T

294 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Интеграция с Bitrix — спецификация для backend-разработчика
Это документ для человека, который не участвовал в разработке самого React-приложения (`portal-chart-builder`) и не обязан знать TypeScript — здесь только то, что нужно на стороне PHP/Bitrix.
## 1. Что это
Конструктор диаграмм — отдельное React-приложение (не часть Bitrix24, продукт — «1С-Битрикс: Управление сайтом»). Встраивается как **пользовательский тип свойства инфоблока**: в админке, на странице редактирования элемента, вместо обычного поля отображается iframe с редактором диаграммы. Значение свойства — JSON-строка со схемой диаграммы, хранится как обычное строковое свойство (`PROPERTY_TYPE => 'S'`).
Никакого REST-модуля, вебхуков или JS SDK Bitrix24 не требуется — это нативный механизм модуля `iblock`.
## 2. Регистрация типа свойства
В `local/php_interface/init.php`:
```php
AddEventHandler("iblock", "OnIBlockPropertyBuildList", ["DiagramProperty", "GetUserTypeDescription"]);
```
После регистрации новый тип появляется в **Контент → Инфоблоки → [нужный] → Свойства → Тип свойства**. Свойство должно быть **одиночным** (`MULTIPLE = N`) — одна диаграмма на элемент.
## 3. Скелет PHP-класса
Ниже — иллюстративный каркас, не готовый к продакшену код. Реализация валидации, экранирования и т.д. — на усмотрение разработчика, но с обязательным соблюдением требований из раздела 5.
```php
class DiagramProperty
{
public static function GetUserTypeDescription(): array
{
return [
'PROPERTY_TYPE' => 'S',
'USER_TYPE' => 'DiagramEditor',
'DESCRIPTION' => 'Конструктор диаграмм процессов',
'GetPropertyFieldHtml' => [__CLASS__, 'GetPropertyFieldHtml'],
'ConvertToDB' => [__CLASS__, 'ConvertToDB'],
'ConvertFromDB' => [__CLASS__, 'ConvertFromDB'],
];
}
public static function GetPropertyFieldHtml($arProperty, $value, $strHTMLControlName): string
{
$inputName = htmlspecialcharsbx($strHTMLControlName['VALUE']);
$rawValue = $value['VALUE'] ?? '';
// Вариант A — рекомендуемый, приложение лежит внутри дерева портала (см. раздел 7):
// appOrigin — это просто origin текущего запроса, appUrl — конкретный файл.
// ВАЖНО: имя файла — не константа. dist/index.html выходит из сборки как
// index-<hash>.html (хеш меняется при каждой значимой пересборке, см.
// раздел 7) — 'index.html' строкой здесь писать нельзя, нужно получать
// актуальное имя каким-то способом на своей стороне (см. раздел 7).
$appOrigin = (CMain::IsHTTPS() ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'];
$appUrl = $appOrigin . '/local/diagram-app/' . GetActualDiagramAppIndexFile(); // пример, не готовая функция
// Вариант B — если приложение развёрнуто отдельным контейнером (см. раздел 7),
// appUrl/appOrigin вместо этого указывают на его отдельный адрес, например:
// $appOrigin = 'http://localhost:5178';
// $appUrl = $appOrigin;
ob_start();
?>
<input type="hidden" name="<?= $inputName ?>" id="<?= $inputName ?>" value="<?= htmlspecialcharsbx($rawValue) ?>">
<iframe id="diagram-frame-<?= $inputName ?>" src="<?= htmlspecialcharsbx($appUrl) ?>"
style="width:100%;height:600px;border:1px solid #ccc"></iframe>
<script>
(function () {
// Читаем initialValue из самого input.value, а не встраиваем JSON в
// инлайн-скрипт — так не нужны JSON_HEX_* флаги вообще (см. раздел 5):
// htmlspecialcharsbx() выше уже достаточно экранирует значение
// HTML-атрибута, а браузер сам декодирует его обратно в текст.
var appOrigin = <?= json_encode($appOrigin) ?>;
var input = document.getElementById(<?= json_encode($inputName) ?>);
var frame = document.getElementById('diagram-frame-<?= $inputName ?>');
window.addEventListener('message', function (event) {
if (event.origin !== appOrigin) return; // обязательная проверка, см. раздел 5
if (!event.data || typeof event.data !== 'object') return;
if (event.data.type === 'diagram:ready') {
frame.contentWindow.postMessage(
{ type: 'diagram:init', payload: input.value || null },
appOrigin // никогда '*', см. раздел 5
);
} else if (event.data.type === 'diagram:change') {
input.value = event.data.payload;
}
});
})();
</script>
<?php
return ob_get_clean();
}
public static function ConvertToDB($arProperty, $value): array
{
// Провалидировать, что VALUE — либо пустая строка, либо синтаксически валидный JSON,
// прежде чем отдавать штатному механизму сохранения.
return $value;
}
public static function ConvertFromDB($arProperty, $value): array
{
return $value;
}
}
```
`GetPublicViewHTML`/`GetPublicEditHTML` сейчас не реализуются — публичная часть сайта не нужна в первой версии (диаграмма видна только в админке при редактировании элемента).
## 4. Контракт postMessage (реализован во frontend, `src/host/bitrixAdapter.ts`)
Обмен — три типа сообщений, строго в этом порядке:
```
1. Страница элемента загружается → мост-скрипт (см. раздел 3) готов, держит initialValue
2. iframe с приложением загружается
3. Приложение → мост: { type: 'diagram:ready' }
4. Мост → приложение: { type: 'diagram:init', payload: <JSON-строка диаграммы или null> }
5. Пользователь редактирует → с debounce ~400мс:
Приложение → мост: { type: 'diagram:change', payload: '<JSON-строка диаграммы>' }
6. Мост при каждом diagram:change пишет payload в value hidden input
7. Пользователь жмёт штатное «Сохранить» → браузер сабмитит форму → ConvertToDB → сохранение
```
Важно: `payload` в `diagram:change` — **уже сериализованная JSON-строка** (`JSON.stringify` со стороны приложения), не объект. Мост должен положить её в `input.value` как есть, без повторной обработки.
`diagram:init` с `payload: null` (или пустой строкой) — валидный случай: новый элемент без диаграммы, приложение стартует с чистого холста.
### Режим только для просмотра
Хост может открыть приложение на чтение, добавив `?mode=view` к URL iframe:
```
/local/diagram-app/index.html?mode=view
```
Тогда редактор стартует в режиме «Просмотр» и переключатель «Просмотр/Редактирование» скрыт — фигуры нельзя двигать, создавать и удалять. Без параметра поведение прежнее: редактирование с переключателем. Параметр читается один раз при загрузке (`src/App.tsx`), в контракте `postMessage` ничего не меняется — `diagram:init`/`diagram:change` работают одинаково в обоих режимах.
Зачем: встраивающая сторона, которая не слушает `diagram:change` (например, страница портала, показывающая утверждённую схему), иначе предлагала бы правки, которые молча никуда не сохраняются.
## 5. Требования безопасности (обязательны, не опциональны)
- Значение всегда попадает на страницу только через `value`-атрибут hidden input (`htmlspecialcharsbx($rawValue)`, см. раздел 3), не через встраивание в инлайн-скрипт — так JSON-содержимое никогда не оказывается внутри JS-исходника, и `JSON_HEX_*`-экранирование не требуется вообще. Мост читает `input.value` напрямую в JS. Если по каким-то причинам всё же понадобится встроить значение в инлайн-скрипт (например, отдельный `initialValue` вместо чтения из DOM) — тогда обязателен `JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT` при `json_encode`.
- `event.origin` проверяется **в обе стороны**: и в мосте (когда он слушает сообщения от iframe), и в приложении (уже реализовано во frontend).
- `targetOrigin` у любого `postMessage` — всегда конкретный домен (`appOrigin`), **никогда `'*'`**.
- `name` у hidden input должен **точно** совпадать с `$strHTMLControlName['VALUE']` — это единственная связь между кастомным рендером и штатным сохранением формы.
## 6. Формат JSON диаграммы
Пример реального значения свойства (то, что лежит в hidden input и уходит в `ConvertToDB`):
```json
{
"nodes": [
{
"id": "start",
"type": "startEvent",
"position": { "x": 0, "y": 80 },
"width": 48,
"height": 48,
"data": { "label": "Заявка получена" }
},
{
"id": "review",
"type": "task",
"position": { "x": 220, "y": 60 },
"width": 140,
"height": 50,
"data": {
"label": "Приём заявок",
"description": "Проводится ответственным лицом, 1-2 дня.",
"responsible": "Иван Иванов",
"duration": "1-2 дня",
"color": "#eef2ff"
},
"parentId": "some-other-node-id",
"zIndex": 1
}
],
"edges": [
{
"id": "start-review",
"source": "start",
"target": "review",
"sourceHandle": "right",
"targetHandle": "left",
"label": "Да"
}
]
}
```
Пояснения по полям (все, кроме перечисленных обязательными, — опциональны и могут отсутствовать):
- `nodes[].id` / `edges[].id` — уникальные строки, обязательны.
- `nodes[].type` — один из: `task`, `startEvent`, `intermediateEvent`, `endEvent`, `exclusiveGateway`, `parallelGateway`, `inclusiveGateway`.
- `nodes[].position` — координаты на холсте, обязательны.
- `nodes[].width`/`height` — размер фигуры (пользователь может менять руками).
- `nodes[].data.label` — название, обязательно; остальные поля `data` — произвольные метаданные, отображаемые в форме редактирования.
- `nodes[].parentId` — если узел вложен в другой (drill-down «под-процесс» внутри той же диаграммы, не переход на другой элемент).
- `nodes[].zIndex` / `edges[].zIndex` — порядок наложения друг на друга.
- `edges[].source`/`target` — id узлов, обязательны.
- `edges[].sourceHandle`/`targetHandle` — с какой стороны фигуры выходит/входит линия (`top`/`right`/`bottom`/`left`).
Backend не обязан ничего валидировать глубже «это либо пустая строка, либо синтаксически корректный JSON» — вся содержательная валидация (обязательные поля и т.п.) — на стороне приложения.
## 7. Деплой приложения
Два варианта. Вариант A проще и ближе к тому, как уже устроен сам портал — рекомендуется. Вариант B (отдельный контейнер) остаётся как альтернатива на случай, если приложение когда-нибудь понадобится хостить отдельно от Bitrix-стенда (другой домен, отдельный CI/CD и т.п.).
### Вариант A (рекомендуется) — статика прямо в дереве Bitrix
Проверено на текущем дев-стенде (`portal-bitrix-docker`): каталог сайта — не часть Docker-образа, а volume `bitrix_www`, смонтированный в контейнеры `nginx` и `php` как bind-mount на каталог **хоста** (`docker volume inspect bitrix_www` → `device: ..., o: bind`). Именно так уже хостится сам портал: файлы лежат на диске хоста, штатный nginx их и раздаёт — значит собранную статику приложения можно положить туда же, без отдельного контейнера.
Что это даёт технически:
- Nginx-конфиг стенда отдаёт файлы по расширению (`js|css|html|svg|png|...`) напрямую с диска — это срабатывает раньше, чем запрос попадёт в PHP-роутер Bitrix. Любой статический файл под корнем сайта отдаётся сам, никаких правок nginx не требуется.
- `local/` уже существует как каталог для кастомного кода (`ai`, `components`, `modules`, `php_interface`, `templates` — по факту на дев-стенде). Новый подкаталог, например `local/diagram-app/`, вписывается в эту конвенцию и не пересекается с `local/modules`/`local/php_interface` — они у nginx явно помечены `internal` (заблокированы для прямого внешнего доступа), новый подкаталог под этот блок не попадает.
- Нюанс: `index` на уровне сервера — `index.php`, не `index.html`. Открыть голый `/local/diagram-app/` (без имени файла) не выйдет — nginx попытается найти там `index.php` и не найдёт. PHP-класс (раздел 3) сам подставляет `src` у iframe — но, в отличие от более ранней версии этого документа, **больше нельзя просто написать `/local/diagram-app/index.html` строкой** — см. следующий пункт.
- Так как приложение и портал теперь на одном домене, `VITE_BITRIX_ORIGIN`/`appOrigin` — это просто origin самого портала, а не адрес отдельного сервиса.
- **Имена файлов в `dist/` — с хешем содержимого, включая сам `index.html`.** `index.js`/`index.css`/шрифты хеширует сам Vite при сборке (стандартное поведение, как в любом Vite-проекте). Кроме того, в `vite.config.ts` есть небольшой плагин (`hashIndexHtml`), который **дополнительно хеширует и сам `index.html`** — на выходе получается что-то вроде `index-2fddeca6.html`, а не просто `index.html`. Имя меняется всякий раз, когда меняется что-то значимое (сама HTML-обёртка либо то, что она подключает), и не меняется при пересборке без реальных изменений.
Из этого следует главное отличие от более ранней версии данного документа: **`$appUrl` в PHP-классе (раздел 3) больше нельзя хардкодить как строку** — конкретное имя `index-<hash>.html` меняется при каждой пересборке приложения. Бэкенду нужен свой способ узнавать актуальное имя файла на момент рендера страницы (например, взять единственный `index-*.html` из каталога деплоя, или через отдельный метод/эндпоинт, который вернёт актуальное имя, — механизм на усмотрение бэкенда, здесь не фиксирован, уточняйте на момент внедрения).
**Готовый механизм — `app.json` рядом с билдом.** Тот же плагин `hashIndexHtml` кладёт в `dist/` файл со стабильным, никогда не меняющимся именем:
```json
{ "index": "index-2fddeca6.html" }
```
Его можно прочитать как с бэкенда при рендере страницы, так и на клиенте — `fetch('<каталог>/app.json', { cache: 'no-store' })`, — и уже к полученному имени дописывать `?mode=view`. `no-store` здесь обязателен: имя файла стабильно, значит сам манифест попадает под тот же 30-дневный `Cache-Control`, что и остальная статика. Этим сейчас пользуется portal-front (`src/helpers/chart-builder.ts`), пока у бэка нет своего метода.
Взамен это полностью снимает необходимость в отдельном cache-busting query-параметре (`?v=...`) — раньше он был обязателен и для самого `index.html`, и (до появления хеша у самого файла) для `index.js`/`index.css` внутри него. Теперь URL меняется вместе с содержимым: старый закэшированный `index-OLDHASH.html` просто никогда больше не запрашивается, как только хост узнаёт новое имя — что убирает разом оба уровня кэш-проблемы, описанной в более ранней версии этого раздела.
Порядок действий:
1. В репозитории `portal-chart-builder` собрать статику с двумя build-time переменными:
```
npm run build
```
Переменные окружения не нужны. `VITE_BITRIX_ORIGIN` в этом варианте не задаётся — см. примечание в конце раздела. Пути к JS/CSS внутри `index.html` относительные (`base: './'` в `vite.config.ts`), поэтому один и тот же `dist/` работает из любого каталога размещения — раньше это задавалось переменной `VITE_BASE_PATH`, которая должна была точно совпадать с каталогом деплоя и при расхождении давала пустой iframe (html грузится, его JS уходит в 404).
2. Скопировать содержимое `dist/` в `local/diagram-app/` внутри host-каталога, который смонтирован как `bitrix_www` (на текущем дев-стенде это `/home/nikit/bitrix/www/local/diagram-app/` — точный путь для конкретного стенда уточняется у того, кто его поднимал/администрирует).
3. В PHP-классе (раздел 3): `$appUrl = $appOrigin . '/local/diagram-app/' . $actualIndexFileName;`, `$appOrigin` — origin портала (без пути), `$actualIndexFileName` — актуальное имя `index-<hash>.html` в `local/diagram-app/`, полученное способом на усмотрение бэкенда (см. врезку про кэш выше — хардкодить `index.html` строкой уже нельзя).
4. Обновление после правок в React-приложении — тот же `npm run build` + копирование поверх старых файлов. Пересборка/перезапуск контейнеров Bitrix не нужны. Старый `index-<hash>.html` от предыдущей сборки при этом не удаляется автоматически — если способ поиска актуального файла (шаг 3) предполагает «взять единственный файл по маске», после нескольких деплоев в каталоге стоит убирать файлы прошлых версий, иначе маска перестанет быть однозначной.
### Вариант B — отдельный Docker-контейнер
В репозитории `portal-chart-builder` есть `Dockerfile` (multi-stage: сборка через node, раздача статики через nginx) и `.dockerignore` — для случая, если приложение должно жить отдельно от Bitrix-стенда (свой домен, свой деплой).
Ключевое рассуждение: iframe грузит **браузер администратора**, а не сервер Bitrix — запроса «сервер → приложение» не происходит вообще. Значит, если контейнер с приложением опубликован портом на тот же хост, откуда админ открывает Bitrix (текущий дев-стенд: это одна и та же машина), достаточно сослаться на `http://localhost:<порт>` — отдельный домен или туннель не нужны.
Пример сервиса для добавления в `docker-compose.yml` Bitrix-стенда (иллюстративный — правки самого файла делает тот, кто ведёт стенд):
```yaml
services:
diagram-app:
build:
context: ../portal-chart-builder
args:
VITE_BITRIX_ORIGIN: http://portal.local # origin самого Bitrix-стенда
container_name: portal_diagram_app
restart: unless-stopped
ports:
- "5178:80" # публикуем на хост
```
Тогда:
- `VITE_BITRIX_ORIGIN` (build-arg, см. раздел 5) = origin Bitrix-портала — `http://portal.local`.
- `$appUrl`/`$appOrigin` в PHP-классе (раздел 3) = адрес самого приложения — `http://localhost:5178`.
Как передать сам аргумент:
- **Через docker-compose** (как в примере выше) — значение прописывается в `build.args` сервиса; при `docker compose up --build`/`docker compose build` compose сам подставит его в сборку, никаких дополнительных флагов не нужно.
- **Через `docker build` напрямую** (ручная сборка, например для локальной проверки) — флагом `--build-arg`: `docker build --build-arg VITE_BITRIX_ORIGIN=http://portal.local -t portal-diagram-app .` — именно так проверялось выше.
- Чтобы не хардкодить реальный адрес прямо в `docker-compose.yml` (он попадёт в git) — вынести значение в `.env`-файл рядом с `docker-compose.yml` (`VITE_BITRIX_ORIGIN=http://portal.local`) и сослаться на него в compose как `VITE_BITRIX_ORIGIN: ${VITE_BITRIX_ORIGIN}` — Compose сам подхватывает `.env` из той же папки.
Собрал и проверил локально (`docker build` + `docker run -p 5178:80`) — контейнер поднимается и корректно отдаёт приложение по `http://localhost:5178`.
**Важно (общее для обоих вариантов):** `VITE_BITRIX_ORIGIN` — build-time переменная, вшивается в JS-бандл на этапе `npm run build`/`docker build`, не читается заново при каждом запуске/обновлении.
В варианте A она **не нужна**: приложение и портал на одном домене, и origin хоста приложение берёт из `window.location.origin`. Более того, заданная переменная там вредна — она перебьёт рантайм-значение, и на любом домене, кроме зашитого, обмен молча не состоится. Нужна она только в варианте B (или при локальной разработке), где хост и приложение на разных origin. Требование «никогда `postMessage('*')`» соблюдается в обоих случаях: targetOrigin всегда конкретный origin.
Схема с `localhost` (вариант B) работает, пока браузер администратора и Docker-хост — одна машина (текущий дев-стенд). Если позже появится реальный удалённый прод с админами не с этой машины — понадобится либо настоящий домен, либо reverse-proxy; это отдельная, более поздняя задача. Вариант A этой проблемы не имеет вообще, так как всегда живёт на домене портала.
## 8. Открытые решения (нужно определить до/во время внедрения)
- **Конкретный инфоблок**, к которому подключается свойство, — не выбран, определяется пользователем/владельцем задачи отдельно. Код из раздела 3 не зависит от конкретного инфоблока — тип свойства регистрируется глобально и включается точечно через админку (см. раздел 2).
- **Публичная часть сайта** — решено, что в первой версии не нужна (только админка). React-приложение уже поддерживает read-only «режим просмотра» — если публичная часть понадобится позже, доработка небольшая (реализовать `GetPublicViewHTML`, встроить тот же iframe в режиме просмотра).
## 9. Definition of done
1. Открыть страницу редактирования элемента (для которого включено свойство) — поле показывает iframe с пустой диаграммой (для нового элемента) или с ранее сохранённой.
2. Отредактировать диаграмму (подвигать фигуры, поменять текст).
3. Нажать штатную кнопку «Сохранить» на странице элемента (не внутри iframe).
4. Перезагрузить страницу — изменения на месте.
5. Открыть значение свойства напрямую в БД/через API инфоблоков — там лежит валидный JSON вида из раздела 6.