MiniShop3 1.14.0: доменные события и исходящие вебхуки

В MiniShop3 1.14.0-beta1 появился механизм доменных событий и контракт для подключения исходящих вебхуков. Он позволяет отделить бизнес-логику магазина от логики интеграций и предоставляет разработчикам дополнений отдельную точку расширения.

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

В этой статье рассмотрим, зачем нужен механизм, как он устроен в v1.14.0-beta1 и какие возможности уже реализованы.

Зачем MiniShop3 нужны доменные события

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

Если логику каждой интеграции встраивать непосредственно в бизнес-процессы магазина, зависимости между компонентами будут усложнять разработку и поддержку. Изменение одного процесса может потребовать корректировок в нескольких интеграциях.

Доменные события позволяют разделить две задачи:

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

Например, при смене статуса заказа MiniShop3 может сформировать order.status_changed. Один обработчик может передать информацию в CRM, другой — записать данные в собственный журнал, а диспетчер вебхуков — отправить событие внешней системе.

Такой подход позволяет:
  • не привязывать бизнес-логику магазина к конкретным внешним сервисам;
  • использовать единый формат событий;
  • подключать несколько независимых обработчиков;
  • развивать интеграции в отдельных дополнениях;
  • расширять набор событий без необходимости строить каждую интеграцию вокруг отдельного участка кода ядра.
В v1.14.0-beta1 реализована инфраструктура этого механизма и генерация события об изменении статуса заказа. Полноценная система исходящей доставки с очередью, повторами и журналом доставки в релиз не входит.

Из чего состоит механизм

В реализации используются три основных компонента.

  • DomainEvent — неизменяемый объект события с идентификатором, типом, временем создания и данными.
  • DomainEventBridge — передаёт событие зарегистрированным PHP-обработчикам и диспетчеру исходящих вебхуков.
  • WebhookDispatcherInterface — контракт, которому должна соответствовать реализация исходящей доставки.
Эти компоненты разделяют формирование события, его обработку внутри PHP и передачу внешним системам. При этом внутренние слушатели и исходящая доставка работают независимо друг от друга.

Формат доменного события

DomainEvent реализует интерфейс JsonSerializable и предоставляет методы:
  • `toArray()`,
  • `eventId()`,
  • `eventType()`,
  • `createdAt()`
  • `data()`.
Каждое событие содержит четыре поля верхнего уровня:
  • event_id — Уникальный идентификатор события в формате UUID v4
  • event_type — Тип события
  • created_at — Время создания в UTC, в формате ISO 8601
  • data — Полезная нагрузка события
Идентификатор позволяет отличать события друг от друга и использовать его для дедупликации при обработке повторных доставок. Поле event_type определяет бизнес-смысл события, а data содержит относящиеся к нему сведения.

Первое реализованное событие — order.status_changed

В v1.14.0-beta1 ядро формирует событие order.status_changed при успешном изменении статуса заказа, если новый статус отличается от предыдущего.

Это происходит в OrderStatusService::change(). Сначала сервис сохраняет новый статус и завершает собственную транзакцию, если он сам её открыл. Затем записывает изменение в историю заказа, вызывает существующее событие MODX msOnChangeOrderStatus и отправляет уведомления. После этого, при соблюдении условия по состоянию транзакции, формируется и передаётся доменное событие.

В полезную нагрузку входят следующие поля:

  • — `order_id` — идентификатор заказа;
  • — `order_uuid` — UUID заказа;
  • — `old_status_id` — идентификатор предыдущего статуса;
  • — `new_status_id` — идентификатор нового статуса;
  • — `cost` — итоговая стоимость заказа;
  • — `cart_cost` — стоимость товаров;
  • — `delivery_cost` — стоимость доставки.
Пример структуры события:

{
  "event_id": "9c2e7a1f-7b3d-4f8a-9d12-6f01e5a8c301",
  "event_type": "order.status_changed",
  "created_at": "2026-10-14T08:00:00Z",
  "data": {
    "order_id": 123,
    "order_uuid": "example-order-uuid",
    "old_status_id": 1,
    "new_status_id": 2,
    "cost": 12500,
    "cart_cost": 11500,
    "delivery_cost": 1000
  }
}

Значения приведены для иллюстрации формата, а не взяты из реального заказа.

Когда событие не формируется

Событие не создаётся при попытке установить уже действующий статус. Оно также не передаётся через мост, если после выполнения операции остаётся открытая транзакция.

Последнее особенно важно: механизм не ставит событие в очередь в ожидании commit. Если транзакция остаётся открытой, вызова DomainEventBridge::emit() не происходит. Это предотвращает отправку события о смене статуса до окончательного подтверждения транзакции, но не обеспечивает отложенную доставку после её завершения.

Кроме того, вызов доменного события зависит от наличия DomainEventBridge в OrderStatusService.

Почему передаются не все данные заказа

Метод DomainEvent::orderStatusChanged() формирует полезную нагрузку по заранее определённому списку полей. В неё не входят имя покупателя, телефон, электронная почта, адрес доставки или полный набор данных товаров заказа.

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

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

Как работает DomainEventBridge

Событие передаётся в DomainEventBridge через метод emit().

Мост выполняет две операции:

  1. Вызывает handle($event) у каждого зарегистрированного слушателя DomainEventListenerInterface.
  2. Вызывает dispatch($event) у диспетчера, реализующего WebhookDispatcherInterface.
Каждый внутренний слушатель обрабатывается в отдельном блоке try/catch. Если слушатель выбрасывает исключение, мост записывает ошибку в журнал и продолжает обработку остальных слушателей.

Вызов диспетчера также защищён обработкой исключений. Его ошибка логируется и не пробрасывается вызывающему коду из DomainEventBridge.

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

Подключение собственного PHP-обработчика

Разработчик дополнения может реализовать DomainEventListenerInterface и зарегистрировать обработчик через сервис ms3_domain_events.

Пример:

<?php

namespace MyAddon;

use MiniShop3\Services\Events\DomainEvent;
use MiniShop3\Services\Events\DomainEventListenerInterface;

final class OrderStatusListener implements DomainEventListenerInterface
{
    public function handle(DomainEvent $event): void
    {
        if ($event->eventType() !== 'order.status_changed') {
            return;
        }

        $data = $event->data();

        $orderId = $data['order_id'] ?? null;
        $newStatusId = $data['new_status_id'] ?? null;

        // Логика дополнения.
    }
}

После инициализации сервисов MiniShop3 обработчик можно зарегистрировать так:

$events = $modx->services->get('ms3_domain_events');
$events->addListener(new \MyAddon\OrderStatusListener());

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

Внутренний обработчик выполняется в PHP-контексте приложения и сам по себе не требует HTTP-запроса. Он может выполнять собственную логику или передавать данные другому компоненту.

DomainEventBridge объявлен как final, и заменять его класс для подключения своей доставки не требуется. Для исходящих вебхуков предусмотрен отдельный сервис — ms3_webhook_dispatcher.

Исходящие вебхуки

WebhookDispatcherInterface определяет единственный метод:

public function dispatch(DomainEvent $event): void;

Он принимает объект доменного события. Конкретная реализация определяет, как и куда передавать данные: например, формировать JSON-тело, подписывать запрос и отправлять его по HTTP.

По умолчанию MiniShop3 использует NullWebhookDispatcher. Его метод dispatch() пустой: он не отправляет HTTP-запросы и не выполняет других действий.

Чтобы включить реальную исходящую доставку, необходимо подключить собственную реализацию диспетчера через сервис ms3_webhook_dispatcher. В ServiceRegistry для этого предусмотрен соответствующий сервисный контракт.

Сам интерфейс не задаёт HTTP-клиент, очередь, повторы, таймауты или хранение результатов доставки. Всё это зависит от реализации конкретного дополнения.

Поэтому корректно говорить, что в релизе появилась инфраструктурная точка подключения исходящих вебхуков, а не готовая система управления вебхуками.

Подпись запросов: HMAC-SHA256

В кодовой базе предусмотрен класс WebhookHmacSigner, который реализует формирование и проверку подписи исходящего вебхука с помощью HMAC-SHA256.

Подпись рассчитывается по строке:

{timestamp}.{rawBody}

Используются два заголовка:

X-MS3-Signature: ...
X-MS3-Timestamp: ...

X-MS3-Signature содержит шестнадцатеричное представление подписи, а X-MS3-Timestamp — временную метку Unix в секундах.

При проверке класс:

— отклоняет пустое тело, подпись или секрет;
— проверяет, что временная метка находится в допустимом временном окне;
— пересчитывает подпись и сравнивает её с полученной с помощью hash_equals().

По умолчанию допустимое отклонение времени составляет 300 секунд.

WebhookHmacSigner — самостоятельный инструмент. Он не отправляет запросы и не подключён автоматически к NullWebhookDispatcher. Формирование подписи и её использование при отправке должны быть реализованы в собственном диспетчере.

Подпись и защита от повторной обработки

Подпись и дедупликация решают разные задачи.

HMAC-SHA256 позволяет получателю проверить, что тело запроса соответствует подписи, рассчитанной с использованием общего секрета, и что запрос укладывается в допустимое временное окно. Для этого получатель должен самостоятельно проверять подпись и хранить секрет в безопасном месте.

Идентификатор event_id предназначен для распознавания повторных доставок. Получателю следует сохранять идентификаторы уже обработанных событий и не выполнять одну бизнес-операцию повторно при поступлении дубликата.

Подпись сама по себе не предотвращает повторную доставку, а наличие event_id не заменяет проверку подлинности запроса.

Доменные события и существующие события MODX

Новый механизм не заменяет события MODX и MiniShop3.

При изменении статуса заказа по-прежнему вызывается msOnChangeOrderStatus. После этого, при выполнении условий в OrderStatusService, формируется отдельное доменное событие order.status_changed.

События MODX остаются частью существующего механизма расширения приложения. Доменные события предоставляют дополнительный формат передачи бизнес-фактов внутренним обработчикам и диспетчеру исходящей доставки.

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

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

DomainEventCatalog содержит константы для нескольких типов событий, в том числе:

  • `order.created`, `order.saved`, `order.removed`;
  • `order.product.created`, `order.product.updated`, `order.product.removed`;
  • `cart.item.added`, `cart.item.changed`, `cart.item.options_changed`, `cart.item.removed`, `cart.emptied`;
  • `customer.created`, `customer.address.added`;
  • `shipment.created`, `shipment.status_changed`, `shipment.tracking_updated`;
  • `inventory.reserved`, `inventory.committed`, `inventory.released`;
  • `import.completed`;
  • `notification.sent`.
В ядре на данный момент формируется order.status_changed, а остальные типы зарезервированы для будущей реализации. Для них описаны предполагаемые соответствия событиям msOn*, но это ещё не означает, что соответствующие бизнес-процессы вызывают DomainEventBridge::emit().

Поэтому наличие константы в каталоге не гарантирует генерацию события при создании заказа, изменении корзины, работе со складом или отправке уведомления.

Что важно учитывать

  • Ядро формирует событиеorder.status_changed при успешной смене статуса заказа.
  • Другие типы из DomainEventCatalog пока зарезервированы.
  • По умолчанию исходящая HTTP-доставка не выполняется.
  • Для отправки вебхуков требуется собственная реализация WebhookDispatcherInterface.
  • WebhookHmacSigner предоставляет методы подписи и проверки, но не отправляет запросы автоматически.
  • Мост логирует исключения слушателей и диспетчера, но не реализует очередь, повторы и гарантированную доставку.
  • Получатель должен самостоятельно реализовать проверку подписи и дедупликацию по event_id.
  • Если после изменения статуса остаётся открытая транзакция, доменное событие через мост не отправляется; отложенной отправки после commit нет.
  • Существующие события MODX, включая msOnChangeOrderStatus, продолжают работать.
Итог

MiniShop3 1.14.0-beta1 добавляет инфраструктуру доменных событий: объект события с единым форматом данных, мост для внутренних PHP-обработчиков и контракт для подключения исходящей доставки.

Первое событие, формируемое ядром, — order.status_changed. Оно передаёт идентификаторы заказа, предыдущий и новый статусы, а также суммы. Для реализации исходящих вебхуков предусмотрен WebhookDispatcherInterface, а для подписи и проверки запросов — WebhookHmacSigner.

Главное назначение изменения — дать дополнениям единый механизм реакции на бизнес-события и уменьшить зависимость интеграций от внутренней реализации MiniShop3. При этом реальная HTTP-доставка, повторные попытки и обработка дубликатов остаются ответственностью подключаемой реализации и получателя.

Исходный код и материалы:

Иван Бочкарев
Иван Бочкарев
44 минуты назад
modx.pro
7

Комментарии: 0

Авторизуйтесь или зарегистрируйтесь, чтобы оставлять комментарии.
0