Источник:
smarty-code/smarty-backend-stable/smarty-system-events/docs/ARCHITECTURE.md
smarty-system-events — АрхитектураДата актуальности: 2026-06-13
Подсистема:smarty-backend-stable/smarty-system-events
Статус: техобзор по исходному коду (READ-ONLY, код не менялся).
smarty-system-events — событийный костяк системы: отдельный консьюмер RabbitMQ,
который слушает очередь system-events и реагирует на «системные события» о действиях
пользователей и рабочих пространств (РП). Это не бизнес-логика домена, а побочные
реакции на уже совершившиеся факты: уведомить поддержку/админа письмом, продублировать
событие в служебный чат (через dialogHook), записать метрику в InfluxDB, выполнить
отложенную/каскадную операцию (удаление аккаунта).
Ключевая идея — развязка (decoupling). Бизнес-код (auth, workspace, sms, ocr, оплата)
не должен знать, как отправляется письмо в поддержку или метрика в Influx; он лишь
объявляет факт: «телефон подтверждён», «баланс пополнен», «распознана визитка». Доставку и
реакцию берёт на себя эта подсистема, работая асинхронно и вне критического пути запроса
(fire-and-forget). Поэтому почти все обработчики — «толстые» по эффектам, но «тонкие» по
входу: они получают минимальный конверт и сами дочитывают нужное из БД.
| Точка входа | Файл | Роль |
|---|---|---|
| Standalone-воркер | worker.js |
Поднимает MicroService, регистрирует очередь system-events с картой обработчиков и запускает (ms.run()). Так подсистема живёт как отдельный процесс/под. |
| Монолитный воркер | deploy/workers.js (вне каталога) |
Тот же eventsMap подключается строкой .addQueue('system-events', systemEvents) рядом с десятком других очередей. Так подсистема живёт «в одном процессе» с прочими консьюмерами. |
| Карта обработчиков | events/index.js |
Единый реестр: имя события → функция-обработчик. Экспортирует HandlersMap. |
Обе точки входа используют один и тот же events/index.js. Разница только в
изоляции процесса (отдельный под против общего воркера). worker.js существует для
независимого масштабирования/деплоя именно событийного консьюмера.
Продюсеры не публикуют в очередь напрямую. Они вызывают методы профиля/пользователя,
которые формируют единый «конверт» события:
profile.systemEvent(type, data)
└─ emitBgEvent.sendEvent(type, ENVELOPE, 'system-events')
└─ mq.publish('system-events', type, ENVELOPE) // RabbitMQ
Конверт (smarty-db/models/system/user.js:269):
{
time: Date.now(), // метка времени публикации
eventData: data, // полезная нагрузка конкретного события
userMeta: this.getMeta(), // метаданные запроса: platform, ip, agent, deviceName, clientVersion, city...
_user: this._id // id пользователя-инициатора
}
Варианты публикации:
| Метод продюсера | Где определён | Поведение |
|---|---|---|
user.systemEvent(type, data) |
smarty-db/models/system/user.js:269 |
Немедленная публикация конверта в system-events. |
employee.systemEvent(type, data) |
smarty-db/models/workspace/employee.js:257 |
То же, но в eventData подмешивается _wsId сотрудника. |
user.delaySystemEvent(type, delay, data) |
user.js:278 |
Отложенное событие через сервис таймеров (queueTimer): сработает в now+delay. Id = ${type}_${userId}. |
user.clearDelayedSystemEvent(type) |
user.js:293 |
Отмена ранее запланированного отложенного события по тому же id. |
emitBgEvent.sendEvent(type, payload, 'system-events') |
smarty-db/lib/emitBgEvent.js:11 |
Прямая публикация без конверта systemEvent — для событий, которым не нужен _user/userMeta (dialogHook, feedbackCrash, individualRateAskPromo, deleteUser, rateConfirm). |
Отложенные события (delaySystemEvent) реализуют «дедлайн-логику»: например, через 2 часа
после регистрации проверить, подтвердил ли пользователь телефон (noPhoneConfirm), а при
подтверждении — снять таймер (clearDelayedSystemEvent('noPhoneConfirm')). Аналогично
noVisitForFiveDays (5 дней неактивности) и userPressFillBalanceButton (снимается при
успешной оплате).
events/index.js)eventsMap = new HandlersMap() — это Map (имя события → функция), расширенный методом
handle. Каждая запись добавляется через .set('имя', handler). Часть обработчиков
оборачивается в обёртки-загрузчики (userEvent, adminEvent), часть регистрируется
«голой».
lib/microservice/HandlersMap.js)MQListener.subscribe отдаёт каждое сообщение в HandlersMap.handle(msg, acker):
fn = this.get(msg.type) — поиск обработчика по типу сообщения.fn есть — await fn(msg). Если нет — тихо пропускается (см. подводные камни).acker.ack() — сообщение подтверждается всегда, в т.ч. после ошибки.msg.chained (массив) — публикуется следующий шаг цепочкиmq.publish(entry.queue, entry.action, entry.data, { chained })). Это механизмlogger.error) и проглатываются (nack отключён). ВNODE_ENV=test исключение пробрасывается — чтобы тесты падали.| Обёртка | Файл | Что делает | Семантика |
|---|---|---|---|
userEvent(fn) |
lib/userEvent.js |
Грузит User.findById(data._user); если найден — fn(user, { data }), иначе null. |
Превращает «голый» конверт в (user, {data}). Если пользователь не найден — событие тихо отбрасывается. |
adminEvent(fn) |
lib/adminEvent.js |
По eventData._wsId находит сотрудника-админа (rightsAdmin ≠ [], _user ≠ null), резолвит его User, подменяет data.userMeta на user.getMeta() и зовёт fn(user, {data}). |
Адресует событие админу РП, а не инициатору. Если админа нет — no-op. |
| (нет обёртки) | — | dialogHook, individualRateAskPromo, deleteUser, rateConfirm, feedbackCrash получают { data } напрямую. |
Этим не нужен User по _user. |
| Домен | События | Основной эффект |
|---|---|---|
| Письма в поддержку (регистрация/онбординг) | phoneConfirmed, noPhoneConfirm, inviteSent, accessRightCreated, noVisitForFiveDays |
Письмо в support через шаблон user_event.pug. |
| Тарифы и биллинг | balanceFill, rateChangeToFree, rateChangeToPaid, userAttemptChangeRate, userPressFillBalanceButton, notEnoughBalance, balanceEmptyInFiveDays, activateOffer, individualRateAsk, individualRateAskPromo, rateConfirm |
Письмо в support/админу и/или метрика; rateConfirm — только Influx. |
Дублирование в служебный чат (dialogHook) |
phoneConfirmed, balanceFill, ocrPerformed, smsSend, callSend, logCityChange |
Эти обработчики дополнительно переэмитят событие dialogHook с заранее заданным токеном чата. |
| Метрики (InfluxDB) | ocrPerformed, smsSend, callSend, rateConfirm |
influxWritePoints(...). |
| SMS/звонки | smsSend, callSend, resendSms, resendCall |
Метрика + чат-уведомление о факте отправки. |
| OCR | ocrPerformed |
Метрика, чат-уведомление, контроль остатка распознаваний (redis-флаг OCR_SUPPORT_NOTIF). |
| Обратная связь / краши | userSupport, feedbackCrash |
Письмо в support (user_event.pug / crash_report.pug). |
| Жизненный цикл аккаунта | deletionReport, deleteUser |
deletionReport — письмо + SupportReport; deleteUser — каскадное удаление (см. §6). |
| Безопасность/аномалии | logCityChange (флаг ENABLE_LOGCITYCHANGE) |
Уведомление в чат о входе из нового города + обновление ipCity/lastIp. |
| Универсальный мост в чат | dialogHook |
Базовый примитив: исполнить hook по токену (object.hookExecute). |
dialogHook (events/dialogHook.js) — низкоуровневый «мост». По token находитWsObjectToken, проверяет, что целевая модель Hookable, грузит объект и вызываетobject.hookExecute({ profile, body }). На нём держатся все чат-уведомления подсистемы:phoneConfirmed, balanceFill, smsSend, callSend, ocrPerformed,logCityChange) переэмитят dialogHook с предсозданными токенами чатов.phoneConfirmed — самый «развилочный» пользовательский обработчик: шлёт чат-хук оdeleteUser — единственный обработчик с тяжёлой транзакционной логикой: курсоромEmployee пользователя и для каждого РП решает — покинуть РПleaveWorkspace) или удалить РП (deleteObject) в зависимости от прав и числа админов;isDeleted; ставит отложенное userDeleted (auth-events),disconnect-other-sessions (socket-commands) и чистит устройстваDevice.purgeDevices). Это оркестрация, а не уведомление.adminEvent-события (notEnoughBalance, balanceEmptyInFiveDays) — адресуются_wsId, обёртка сама находит получателя.lib/, models/, templates/)| Компонент | Файл | Роль |
|---|---|---|
userEventSend |
lib/userEventSend.js |
Высокоуровневый помощник: собирает данные пользователя, рендерит user_event.pug и шлёт письмо в support. Базовый «кирпич» большинства обработчиков. |
generateUserInfo |
lib/generateUserInfo.js |
Обогащение: список РП пользователя, разбор User-Agent, способ регистрации, промокод, язык, UTM. Делает доп. запросы в БД (Workspace, Employee). |
render |
lib/render.js |
Рендер pug-шаблона из templates/ с санацией строк (clearString, whitelist тегов br, фильтрация URL) — защита от инъекций в письмо. |
mailgunSend |
lib/mailgunSend.js |
Низкоуровневая отправка через Mailgun API (multipart, basic-auth ключом). |
sendEmailToSupport |
lib/sendEmailToSupport.js |
Выбор получателя по NODE_ENV (prod→supportEmail, иначе→testSupportEmail; test→заглушка), отправка и персист письма в коллекцию Mail. |
Mail (модель) |
models/mail.js |
Лог исходящих писем (to/from/subject/html/status). Содержит checkSendLimit (≤10000/мес) — но в текущем пути отправки он не вызывается. |
| Шаблоны | templates/*.pug |
user_event — карточка пользователя/РП; promo_event — короткое письмо; crash_report — отчёт о краше. |
HandlersMap.handle всегда делает ack(), в т.ч.nack() намеренно отключён (// TODO в коде). Упавшее событиеdeleteUser здесь — пограничный случай: при сбое в середине каскада состояние можетmsg.type → тихий no-op. Если в очередь попадёт тип без обработчика,fn будет undefined, сообщение просто подтвердится. Опечатка в имени события уuserEvent отбрасывает событие без пользователя. Если User.findById(_user) вернётnull (например, пользователь уже удалён к моменту обработки отложенного события) —phoneConfirmed, balanceFill,ocrPerformed, smsSend, callSend, logCityChange) UUID-токены целевых чатовdialogHook зашиты прямо в исходники как строковые литералы (в этой документации —<секрет>). Это эффективно конфигурация в коде: смена чата требует правки и деплоя, аadminEvent мутирует конверт. Object.assign(data, { userMeta: ... }) перезаписываетuserMeta инициатора на метаданные админа. Для писем это нужный получатель, но важноuserMeta в eventData — это уже не данные инициатора.logCityChange под флагом. Регистрируется только при process.env.ENABLE_LOGCITYCHANGE.Mail.checkSendLimit() существует, но путьsendEmailToSupport → mailgunSend его не проверяет — защиты от «шторма» писем здесь нет.worker.js и deploy/workers.js независимо вешаютsystem-events на eventsMap. В одном кластере должен слушать кто-то один, иначе —dialogHook-сообщение. При отладке учитывать, чтоdialogHook приходит в ту же очередь повторно.resendCall. Зарегистрирован в events/index.js, но во всейresendSms). Мёртвый код либо забытый недоделанный сценарий.eventData._wsId,userMeta.platform/ip/agent/city/clientVersion — эти поля должен заполнить продюсерgetMeta() из метаданных запроса). Отсутствие поля проявится как undefined в письме,system-events — одна из очередей RabbitMQ в общем event-driven контуре Smarty CRM (рядом
с auth-events, dialog-events, notifications, statistic, dbca и др., см.
deploy/workers.js). Подсистема одновременно консьюмер (слушает system-events) и
продюсер: переэмитит dialogHook в свою же очередь, а из deleteUser публикует в
auth-events (userDeleted, отложенно), socket-commands (disconnect-other-sessions),
пишет в InfluxDB и шлёт письма через Mailgun. Транспорт публикации — smarty-db/lib/emitBgEvent.js
поверх smarty-db/db/mq.
Документ описывает наблюдаемое поведение по состоянию кода на 2026-06-13. Контракты
отдельных событий — см. API.md.