Клиентские события (ClientEvents)

Назначение

Клиентские события (ru.bgcrm.event.client.ClientEvent и наследники) — механизм доставки UI-сигналов пользователю: показать уведомление (ShowInfoEvent/ShowErrorEvent), индикатор загрузки (ShowLoadingEvent/HideLoadingEvent), открыть вкладку процесса (ProcessOpenEvent) и т.д. Фронтенд обрабатывает их через processEvent — компоненту для этого ничего делать не нужно.

Документ описывает, каким каналом отправлять события в разных контекстах и как пользоваться фасадом ClientEvents, который убирает типовой boilerplate.

Два канала доставки

Контекст API Как доходит до пользователя

Внутри HTTP-запроса (синхронно)

form.getResponse().addEvent(event)

событие уезжает в eventList JSON-ответа текущего запроса

Вне запроса (фон, AsyncWorker, слушатели)

ClientEventNotification.pushEvent(userId, event)

событие кладётся в Redis-очередь clientEventsService:<userId>, фронт забирает поллингом (popEvent)

Предусловие для внеполосного канала: события через Redis работают только при включённой настройке clientEvents.saveToRedis.enabled=1. При выключенной настройке pushEvent/pushEvents — no-op (событие молча не отправится). TTL очереди — clientEvents.saveToRedis.ttl (по умолчанию 300 секунд).

userId в асинхронном контексте передавайте явно. По умолчанию задача в AsyncWorker выполняется от системного пользователя (USER_BGCRM).

Фасад ClientEvents

ru.bgcrm.event.client.ClientEvents — статические хелперы над pushEvent с форматированием сообщений (String.format, если переданы аргументы):

ClientEvents.showInfo(userId, "Устройство заведено по процессу %s", processId);
ClientEvents.showError(userId, "Ошибка по процессу %s", processId);
ClientEvents.openProcess(userId, processId);
ClientEvents.showLoading(userId, uuid, "Отправка данных...");
ClientEvents.hideLoading(userId, uuid);
Если аргументы не переданы, строка отправляется как есть (без String.format) — символ % в тексте безопасен.

Loading — индикатор загрузки с гарантированным закрытием

ClientEvents.loading(…​) возвращает Loading (AutoCloseable): uuid генерируется внутри, close() гарантированно шлёт HideLoadingEvent — в том числе при исключении, если использовать try-with-resources. «Вечные часики» и рассинхрон Show/Hide становятся невозможны.

try (var loading = ClientEvents.loading(userId, "Отправка в UGIN по процессу %s", processId)) {
    ResponsePerimeter r = perimeterResponseHandler(processId);
    loading.info("Устройство заведено по процессу %s", processId)
           .openProcess(processId);
} // HideLoadingEvent уйдёт и при успехе, и при исключении

AsyncWorker.submitWithLoading

Типовой сценарий «долгий внешний вызов + лоадер» закрыт одним примитивом ru.bgcrm.context.async.AsyncWorker#submitWithLoading:

  • показывает лоадер до старта задачи;

  • гарантированно скрывает его после завершения (успех или исключение);

  • при исключении дополнительно шлёт пользователю ShowErrorEvent и пробрасывает исключение дальше (телеметрия AsyncWorker фиксирует FAILURE).

AsyncWorker.submitWithLoading(
        String.format("Отправка информации в UGIN по процессу %s", processId),
        "AccessControlDeviceAutomation",   // статический тип для телеметрии, без ID/UUID
        user,                              // получатель лоадера и контекст выполнения задачи
        () -> {
            Connection con = ServerContext.get().getConnection(ERP_MASTER); // локально, не в поле!
            ResponsePerimeter r = perimeterResponseHandler(con, processId);
            responseStatusHandler(user.getId(), con, processId, r);
        });

user — это одновременно получатель уведомлений и пользователь, под которым выполняется задача: submitWithLoading пробрасывает его в AsyncWorker.submit, поэтому записи в БД атрибутируются реальному пользователю, а не системному.

user обязателен по смыслу, хотя формально у него есть значение по умолчанию (USER_BGCRM). Лоадер показывать можно только реальному пользователю — тому, кто читает свою очередь клиентских событий. У синтетических пользователей id неположительный (User.USER_SYSTEM_ID = 0, User.USER_CUSTOMER_ID = -1; USER_BGCRM конструируется без id и потому тоже равен 0), поэтому вызов с таким пользователем отклоняется с IllegalArgumentException. Захватывайте пользователя до вызова: внутри задачи в контексте уже системный пользователь.
Не сохраняйте Connection, processId и прочее состояние в полях слушателя-синглтона из асинхронной лямбды — это гонка и шаринг JDBC-соединения между потоками. Всё передавайте параметрами лямбды локальными переменными.

Пример: до и после

Сценарий: по смене статуса процесса выполнить долгий вызов внешней системы, показать пользователю лоадер и результат.

Было — вручную (много строк, легко ошибиться: uuid руками, повтор user.getId(), Hide можно потерять при исключении)
event.getForm().getResponse().addEvent(
        new ShowLoadingEvent(PREFIX + processId, "Отправка информации в UGIN по процессу " + processId));
AsyncWorker.submit("AccessControlDeviceAutomation", () -> {
    ResponsePerimeter r;
    try {
        r = perimeterResponseHandler(processId);
    } catch (Exception e) {
        r = ResponsePerimeter.error(e.getMessage());
    }
    if (r.getSuccess()) {
        ClientEventNotification.pushEvent(user.getId(),
                new ShowInfoEvent(String.format("Устройство успешно заведено по процессу %s", processId)));
    } else {
        ClientEventNotification.pushEvent(user.getId(),
                new ShowErrorEvent(String.format("Ошибка при заведении устройства по процессу %s", processId)));
    }
    ClientEventNotification.pushEvent(user.getId(), new ProcessOpenEvent(processId));
    ClientEventNotification.pushEvent(user.getId(), new HideLoadingEvent(PREFIX + processId)); // забыли try/finally — «вечные часики»
});
Стало — submitWithLoading + ClientEvents
int userId = user.getId(); // захват ДО submit — в async контексте системный пользователь

AsyncWorker.submitWithLoading(
        String.format("Отправка информации в UGIN по процессу %s", processId),
        "AccessControlDeviceAutomation",
        user,
        () -> {
            ResponsePerimeter r = perimeterResponseHandler(processId);
            if (r.getSuccess()) {
                ClientEvents.showInfo(userId, "Устройство успешно заведено по процессу %s", processId);
            } else {
                ClientEvents.showError(userId, "Ошибка при заведении устройства по процессу %s", processId);
            }
            ClientEvents.openProcess(userId, processId);
        });
// лоадер показан до старта и гарантированно скрыт после; при исключении — авто-ShowError

Батч-отправка

Несколько событий одному пользователю можно отправить за один round-trip в Redis:

ClientEventNotification.pushEvents(userId, List.of(
        new ShowInfoEvent("Готово"),
        new ProcessOpenEvent(processId)
));

Порядок событий сохраняется, пустой список игнорируется.

Справка

Что Где

База событий

ru.bgcrm.event.client.ClientEvent (+ реестр подтипов в ClientEventMixin)

Внеполосный канал

ClientEventNotification (pushEvent, pushEvents, popEvent; Redis-ключ clientEventsService:<userId>)

Фасад

ClientEvents (showInfo, showError, openProcess, showLoading/hideLoading, loading)

Async + лоадер

AsyncWorker.submitWithLoading(message, type, user, task)user обязателен по смыслу

Настройки

clientEvents.saveToRedis.enabled, clientEvents.saveToRedis.ttl

При добавлении нового типа события не забудьте зарегистрировать его в ClientEventMixin (@JsonSubTypes) — иначе popEvent не сможет его десериализовать и событие будет молча отброшено.