null

Программное управление уведомлениями в Liferay: подход на базе Liferay Objects

Введение


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

  • Отправка уведомлений в «колокольчик» (User Notification).

  • Дублирование на Email.

  • Важно: Хранение истории всех отправленных уведомлений для аудита.

Обычно, когда Liferay-разработчику нужно отправить уведомление, он смотрит в сторону OSGi сервисов, таких как UserNotificationEventLocalService. Однако этот путь имеет недостатки:

  1. Сложность: Требуется писать много бойлерплейт-кода для создания payload'а.

  2. Отсутствие истории: Стандартный механизм уведомлений — это «доставка». Если пользователь прочитал и удалил уведомление, оно исчезает.

  3. Требования бизнеса: В моем случае требовался аудит. Мы должны были хранить историю: кому, когда и что было отправлено.

В экосистеме Liferay (особенно версий 7.4+) существует множество способов решить одну и ту же задачу. Начав исследовать нативные Liferay OSGi сервисы, я понял, что прямого и простого способа «просто отправить уведомление и сохранить его» нет. Обычно это требует написания собственных сущностей, service-layer'а и довольно громоздкого кода для формирования пейлоада уведомления.

Писать с нуля кастомный модуль с таблицами БД только ради лога уведомлений показалось излишеством. Я обратил внимание на Liferay Objects — инструмент, который позволяет создавать сущности и автоматически генерировать для них REST API.

В этой статье я покажу, как программно настроить эту систему через REST API и как выглядит сервис отправки. 

Архитектурное решение

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

Идея проста:

  1. Мы создаем Liferay Object (назовем его SimpleNotification).

  2. Добавляем ему поля: кому отправить (ID, Email) и текст сообщения.

  3. Настраиваем Notification Templates (шаблоны уведомлений).

  4. Создаем Object Actions — триггеры, которые срабатывают onAfterAdd (после создания записи объекта) и отправляют уведомление по шаблону.

Таким образом, отправка уведомления сводится к одному REST-запросу: создать запись в объекте. Liferay сам сохранит данные (история) и сам разошлет уведомления (триггеры). Ниже я подробно опишу, как автоматизировать создание этой инфраструктуры на Kotlin (Spring Boot), чтобы не «накликивать» это каждый раз вручную в Control Panel.

Этап 1: Автоматическая конфигурация (Infrastructure as Code)

Мы будем использовать подход "Configuration as Code". При старте приложения специальный компонент проверяет, применены ли настройки, и если нет — создает их через Headless API самого Liferay.

1. Определение объекта (Object Definition).

if (isConfigApplied()) {
    logger.info("Configuration '$configName' already applied. Skipping...")
    return
}

val objectDef = client.post()
    .uri("/object-admin/v1.0/object-definitions")
    .body(mapOf(
        "label" to mapOf("ru_RU" to "SimpleNotification"),
        "name" to "SimpleNotification",
        "pluralLabel" to mapOf("ru_RU" to "SimpleNotifications"),
        "scope" to "company",
        "enableObjectEntryHistory" to false,
        "system" to false
    ))
    .buildAdminAuth(adminName, adminPassword)
    .retrieve()
    .body(Map::class.java)

val objectDefinitionId = objectDef?.get("id") as Number

Важно: Мы используем scope: company, чтобы уведомления можно было отправлять пользователям вне зависимости от конкретного сайта.

2. Структура данных (Поля)

Определяем, из чего состоит наше уведомление. Нам нужно три поля: ID получателя (чтобы Liferay знал, кому слать), Email (для дублирования) и само Сообщение.

fun createField(fieldName: String, type: String, label: String) {
    client.post()
        .uri("/object-admin/v1.0/object-definitions/$objectDefinitionId/object-fields")
        .body(mapOf(
            "businessType" to type,
            // Маппинг типов: LongInteger в базе становится BIGINT, Text -> VARCHAR/CLOB
            "DBType" to if(type=="LongInteger") "Long" else "String", 
            "label" to mapOf("ru_RU" to label),
            "name" to fieldName,
            "required" to true
        ))
        .buildAdminAuth(adminName, adminPassword)
        .retrieve()
        .body(Map::class.java)
}

createField("recipientUserId", "LongInteger", "recipientUserId")
createField("recipientUserMail", "Text", "recipientUserMail")
createField("message", "Text", "message")

 

3. Публикация объекта

Это критический шаг. Просто создать объект недостаточно — он будет в статусе "Draft". Чтобы Liferay сгенерировал для него API эндпоинты (/o/c/simplenotifications) и создал таблицы в БД, его нужно опубликовать.

client.post()
    .uri("/object-admin/v1.0/object-definitions/$objectDefinitionId/publish")
    .buildAdminAuth(adminName, adminPassword)
    .retrieve()
    .body(Map::class.java)

 

Этап 2: Шаблонизация и Триггеры

Теперь, когда у нас есть "контейнер" для данных, нужно научить Liferay отправлять уведомления при поступлении этих данных.

1. Шаблоны уведомлений (Notification Templates)

Мы создаем два шаблона. Обратите внимание на синтаксис [%ObjectName_FieldName%]. Это плейсхолдеры, которые Liferay заполнит значениями из полей нашего объекта.

А. Уведомление в «Колокольчик»:

val userNotification = client.post()
    .uri("/notification/v1.0/notification-templates")
    .body(mapOf(
        "name" to "SimpleNotificationTemplate",
        "type" to "userNotification",
        "recipientType" to "term",
        "subject" to mapOf("ru_RU" to "[%SIMPLENOTIFICATION_MESSAGE%]"),
        "recipients" to listOf(mapOf("term" to "[%SIMPLENOTIFICATION_RECIPIENTUSERID%]")) 
    ))
    .buildAdminAuth(adminName, adminPassword)
    .retrieve()
    .body(Map::class.java)
        
val userTemplateId = userNotification?.get("id") as Number

 

Б. Уведомление на Email: Для Email структура сложнее, так как используется XML-конфигурация получателей.

val emailNotification = client.post()
    .uri("/notification/v1.0/notification-templates")
    .body(mapOf(
        "name" to "SimpleNotificationTemplateMail",
        "type" to "email",
        "recipientType" to "email",
        "subject" to mapOf("ru_RU" to "Уведомление"),
        "recipients" to listOf(mapOf(
            "from" to "no_reply@liferay.com",
            "to" to "<root available-locales=\"ru_RU\" default-locale=\"ru_RU\"><Value language-id=\"ru_RU\">[%SIMPLENOTIFICATION_RECIPIENTUSERMAIL%]</Value></root>",
            "singleRecipient" to "true"
        )),
        "body" to mapOf("ru_RU" to "<p>[%SIMPLENOTIFICATION_MESSAGE%]</p>")
    ))
    .buildAdminAuth(adminName, adminPassword)
    .retrieve()
    .body(Map::class.java)

val emailTemplateId = emailNotification?.get("id") as Number

 

2. Действия (Object Actions)

Финальная связка. Мы создаем Action, который говорит: «Когда в SimpleNotification добавляется запись (onAfterAdd), исполни (notification) указанный шаблон».

fun createAction(name: String, label: String, templateId: Number) {
    client.post()
        .uri("/object-admin/v1.0/object-definitions/$objectDefinitionId/object-actions")
        .body(mapOf(
            "active" to true,
            "label" to mapOf("ru_RU" to label),
            "name" to name,
            "objectActionExecutorKey" to "notification",
            "objectActionTriggerKey" to "onAfterAdd",
            "parameters" to mapOf("notificationTemplateId" to templateId)
        ))
        .buildAdminAuth(adminName, adminPassword)
        .retrieve()
        .body(Map::class.java)
}

createAction("triggerSimpleNotification", "TriggerSimpleNotification", userTemplateId)
createAction("triggerSimpleNotificationMail", "TriggerSimpleNotificationMail", emailTemplateId)

markConfigApplied()

 

Этап 3: Клиентский сервис (Runtime)

Теперь инфраструктура готова. Нам нужен сервис, который будет использоваться бизнес-логикой для отправки уведомлений.

Благодаря Liferay Objects, мы получили REST API по адресу /o/c/simplenotifications. Наш сервис просто делает POST-запрос на этот адрес.

Особенности реализации:

  1. Асинхронность: Мы используем Coroutines (Dispatchers.IO), чтобы отправка уведомления не блокировала основной поток выполнения бизнес-логики.

  2. Шаблонизация текста: Чтобы не передавать "сырой" текст каждый раз, реализован метод toNotificationRequest с поддержкой подстановок через StringSubstitutor.

private val client: RestClient = restClientBuilder
    .baseUrl("$baseUrl/o/c/simplenotifications")
    .build()

data class NotificationRequest(
    val recipientUserId: Long,
    val recipientUserMail: String,
    val message: String,
    val substitutions: Map<String, Any?>,
    // Важно при создании объекта выдать разрешение на его просмотр, иначе пользователь не получит уведомление 
    val permissions: List<Permission> = listOf(Permission(listOf("VIEW"), "User"))
)

data class Permission(
    val actionIds: List<String>,
    val roleName: String
)

fun sendNotification(request: NotificationRequest) {
    CoroutineScope(Dispatchers.IO).launch {
        try {
            client.post()
                .header("Content-Type", "application/json")
                .body(request)
                .buildAdminAuth(adminName, adminPassword)
                .retrieve()
        } catch (e: RestClientResponseException) {
            logger.error("Failed to send notification to userId=${request.recipientUserId}: ${e.responseBodyAsString}", e)
        } catch (e: Exception) {
            logger.error("Unexpected error while sending notification to userId=${request.recipientUserId}", e)
        }
    }
}

 

И вспомогательный метод для удобного вызова из кода:

// Пример использования: 
// user.toNotificationRequest("Привет, %(username)!", mapOf("username" to "Ivan"))
fun LiferayUserDto.toNotificationRequest(message: String, substitutions: Map<String, Any?>) = NotificationRequest(
    recipientUserId = this.id,
    recipientUserMail = this.email,
    message = StringSubstitutor(substitutions, "%(", ")").replace(message),
    substitutions = substitutions
)

 

Итог

Используя этот подход, мы получили:

  1. Гибкость: Настройка уведомлений происходит программно (Configuration as Code), но при этом использует движок Liferay.

  2. Аудит: Таблица SimpleNotification (доступная в Control Panel -> Objects) автоматически хранит историю всех отправленных сообщений. Мы можем зайти в админку и посмотреть, кому и что уходило.

  3. Простота API: Нам не пришлось писать OSGi модули для UserNotificationEvent. Мы просто делаем POST запрос.

Это решение отлично подходит для интеграции внешних систем с Liferay или для микросервисной архитектуры, где Liferay выступает в роли Headless-платформы.

Вперед