Перейти к содержимому

Приём высоконагруженных вебхуков Pub/Sub и сквозная AdTech-телеметрия в Odoo

Инженерный пост-мортем промышленной эксплуатации: построение очередей GCP Pub/Sub, ликвидация утечек памяти в SPA и сквозная SQL-атрибуция конверсий в PostgreSQL.

В современной корпоративной архитектуре ERP-система перестает быть замкнутым учетным островом. Она становится транзакционным ядром омниканальной экосистемы, непрерывно поглощающим высокоскоростные потоки внешних событий: входящие сообщения WhatsApp, push-очереди Google Cloud Pub/Sub, отчеты браузеров о нарушениях политик безопасности Content Security Policy (CSP) и алгоритмическую рекламную телеметрию Google Ad Manager (GAM).

Обработка телеметрии реального времени и асинхронных вебхуков на платформе Odoo (Python/PostgreSQL) сопряжена со специфическими инженерными вызовами. Штатно веб-контроллеры Odoo спроектированы для синхронных запросов пользователей. При резком наплыве параллельных внешних событий наивные реализации моментально приводят к четырем тяжелым авариям:

  1. Брокеры очередей гарантируют доставку по модели «хотя бы один раз» (at-least-once delivery), поэтому повторная доставка вебхука в условиях высокой конкурентности вызывает состояние гонки (race conditions), приводя к двойному списанию платежей или дублированию складских резервов при отсутствии проверки идемпотентности.
  2. При совмещении серверного рендеринга рекламных блоков (SSR) с клиентской архитектурой одностраничного приложения (SPA) фреймворка OWL прямые манипуляции со страницей сторонними скриптами вызывают утечки памяти, зависание зомби-слушателей и панику сборщика мусора.
  3. Публичные эндпоинты браузерной телеметрии (такие как /csp-violation) открывают отдельные транзакции СУБД на каждый входящий запрос, а при наплыве сканеров уязвимостей это моментально исчерпывает пул подключений PostgreSQL (db_maxconn), делая ERP-систему недоступной для сотрудников.
  4. Автоматизированные скрипты и внешние AI-агенты обнаруживают, что при отправке форматированных HTML-отчетов в ленту Discuss через протокол XML-RPC все теги превращаются в видимый мусор <div> из-за особенностей обработки класса markupsafe.Markup.

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

Высоконагруженный конвейер приема сообщений Pub/Sub и идемпотентности

01

Входной шлюз и фильтрация протокола

Прокси-сервер направляет запрос на контроллер type='http'; отклоняются не-POST запросы и проверяется токен авторизации.

02

Криптографическая проверка HMAC

Контроллер извлекает заголовок X-Hub-Signature-256 и сверяет цифровую подпись через hmac.compare_digest с секретным ключом.

03

Атомарная блокировка идемпотентности

Поиск в таблице dlab.idempotency.log пары (message_id, channel_type); дубликаты получают мгновенный ответ 200 Duplicate Ack.

04

Контекст трассировки и асинхронный запуск

Формирование сквозного trace_id, добавление в request.context и передача нормализованных данных воркерам очередей.

05

Детерминированное подтверждение доставки

Возврат HTTP 200 OK при успехе; возврат HTTP 500 при внутренних ошибках для запуска экспоненциального бэкоффа в Pub/Sub.

Почему при приеме очередей Pub/Sub обязателен type='http'


При интеграции push-подписок Google Cloud Pub/Sub с Odoo начинающие инженеры обычно создают маршруты с декоратором типа JSON:

@http.route('/webhook/push', type='json', auth='public', methods=['POST'])
def receive_push(self):
data = request.jsonrequest
# ... логика обработки ...

Архитектурная ловушка

В диспетчере запросов Odoo (odoo.http.JsonRequest) любое необработанное исключение Python, возникшее внутри маршрута с type='json', автоматически перехватывается внутренним RPC-обработчиком. Odoo преобразует трейсбек ошибки в структуру JSON-RPC:

{"jsonrpc": "2.0", "id": null, "error": {"code": 200, "message": "Odoo Server Error", "data": {...}}}

Главная опасность заключается в том, что Odoo отдает этот ответ с кодом HTTP 200 OK.

Очередь сообщений Google Cloud Pub/Sub опирается исключительно на транспортные коды протокола HTTP:

  • Коды HTTP 200, 201 или 204 подтверждают успешную обработку сообщения (ack), после чего брокер Pub/Sub удаляет его из очереди.
  • Коды HTTP 500, 503 или 429 сигнализируют об ошибке обработки (nack), заставляя Pub/Sub сохранить сообщение и запланировать повторную отправку с нарастающим интервалом (экспоненциальный бэкофф вплоть до 7 дней).

Поскольку Odoo возвращает статус HTTP 200 OK даже при дедлоке базы данных, обрыве соединения или нулевом значении поля, брокер Pub/Sub считает транзакцию завершенной и навсегда удаляет сообщение из очереди. В результате финансовые уведомления, подтверждения оплат и заказы клиентов бесследно теряются.

Промышленное решение

Точки входа вебхуков обязаны проектироваться строго с декоратором @http.route(..., type='http'). Это дает разработчику прямой контроль над возвращаемыми заголовками и кодами статуса:

# -*- coding: utf-8 -*-
from odoo import http, fields
from odoo.http import request, Response
import logging
import uuid
import hmac
import hashlib

_logger = logging.getLogger(__name__)

class PubSubController(http.Controller):

# Строго type='http'. type='json' маскирует сбои под 200 OK, ломая повторные попытки Pub/Sub.
@http.route('/dlab/webhook/pubsub_push/<string:token>',
type='http', auth='public', methods=['GET', 'POST'], csrf=False)
def receive_pubsub_push(self, token, **post):
try:
# 1. Проверка секретного токена маршрута
expected_token = request.env['ir.config_parameter'].sudo().get_param(
'dlab.pubsub_token', default='invalid_default'
)
if token != expected_token:
_logger.warning("Попытка несанкционированного доступа к вебхуку с неверным токеном.")
return Response("Forbidden", status=403)

# 2. Извлечение JSON и заголовков
json_data = request.get_json_data()
if not json_data:
return Response("Empty Payload", status=400)

raw_body = request.httprequest.get_data()
meta_sig = request.httprequest.headers.get('X-Hub-Signature-256')
trace_id = f"msg_{uuid.uuid4().hex[:8]}"

# 3. Криптографическая проверка подписи HMAC (WhatsApp / Meta)
if meta_sig:
app_secret = request.env['ir.config_parameter'].sudo().get_param('dlab.meta.app_secret', default='')
expected_hash = hmac.new(app_secret.encode('utf-8'), raw_body, hashlib.sha256).hexdigest()
expected_sig = f"sha256={expected_hash}"

if not hmac.compare_digest(expected_sig, meta_sig):
_logger.critical("Сбой верификации цифровой подписи HMAC!")
return Response("Invalid Signature", status=403)

channel_type = "whatsapp"
message_id = json_data['entry'][0]['changes'][0]['value']['messages'][0]['id']

# 4. Обработка стандартного пакета Google Cloud Pub/Sub
elif 'message' in json_data and 'messageId' in json_data['message']:
channel_type = "gcp_pubsub"
message_id = json_data['message']['messageId']
else:
return Response("Unprocessable Entity", status=422)

# 5. Атомарный контроль идемпотентности
IdempotencyLog = request.env['dlab.idempotency.log'].sudo()
if IdempotencyLog.is_processed(message_id, channel_type):
_logger.info("Дублирующееся сообщение %s безопасно отклонено.", message_id)
return Response("Duplicate Ack", status=200)

# 6. Передача в прикладной бизнес-уровень
success = request.env['dlab.pubsub.normalizer'].sudo().process_payload(
channel_type, json_data, trace_id
)
if not success:
# Возврат кода 500 для повторной доставки брокером Pub/Sub
_logger.error("Ошибка прикладной обработки сообщения %s", message_id)
return Response("Internal Processing Failure", status=500)

return Response("OK", status=200)

except Exception as e:
_logger.exception("Необработанная системная ошибка вебхука: %s", str(e))
# Критично: возврат HTTP 500 активирует механизм повторов Pub/Sub
return Response("Internal Server Error", status=500)

Ликвидация утечек оперативной памяти в SPA-приложениях фреймворка OWL


При внедрении сложных рекламных систем (включая Google Ad Manager через скрипт gpt.js) в портальную часть Odoo разработчики часто сталкиваются с бесконтрольным ростом потребления памяти и аварийным завершением работы интерфейса.

Клиентская маршрутизация SPA против сторонних мутаций DOM

В современных темах Odoo переходы между страницами блога и каталога осуществляются через асинхронный роутер без перезагрузки всей страницы (publicWidget или компоненты OWL). При клике на статью браузер заменяет внутреннее содержимое контейнера #wrap динамически.

Внешние библиотеки (такие как Google Publisher Tag googletag) сохраняют внутренний реестр всех объявленных на странице рекламных слотов:

window.googletag.defineSlot('/23345748244/ad_unit', [300, 250], 'div-gpt-ad-1');

Когда пользователь переходит со Статьи А на Статью Б:

  1. Элементы DOM, содержащие слот div-gpt-ad-1, удаляются движком Odoo из документа.
  2. Новый HTML вставляется в дерево, создавая новый элемент с тем же идентификатором div-gpt-ad-1.
  3. Если JavaScript-виджет просто очищает локальную переменную window.dlab_defined_slots = {}, старые слоты навсегда остаются в глобальной памяти библиотеки googletag.
  4. При попытке инициализации нового слота googletag.display('div-gpt-ad-1') библиотека падает с ошибкой: Error: Slot 'div-gpt-ad-1' was already registered on this page.
  5. Зависшие ссылки блокируют сборку мусора, приводя к утечке до 50 МБ оперативной памяти на каждый переход по страницам.

Жизненный цикл SPA и шаблон двухшаговой записи XML-RPC

/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";

export const DlabGamBlogEngine = publicWidget.Widget.extend({
selector: '#o_wblog_post_main',

destroy: function () {
// Критично: Явное разрегистрирование слотов из googletag перед очисткой ссылок
if (window.googletag && googletag.destroySlots) {
// 1. Очистка динамически созданных слотов внутри статьи
if (window.dlab_defined_slots && Object.keys(window.dlab_defined_slots).length > 0) {
googletag.destroySlots(Object.values(window.dlab_defined_slots));
window.dlab_defined_slots = {};
}
// 2. Очистка статических слотов шапки и мультиплекса
if (window.dlab_spa_slots && window.dlab_spa_slots.length > 0) {
googletag.destroySlots(window.dlab_spa_slots);
window.dlab_spa_slots = [];
}
}
this._super.apply(this, arguments);
}
});
# Решение проблемы экранирования HTML-тегов в XML-RPC:
# Шаг 1: Создание сообщения через message_post (экранирует строку)
msg_id = models.execute_kw(db, uid, pwd, 'blog.post', 'message_post', [[post_id]], {
'body': 'Инициализация аудит-лога',
'message_type': 'comment',
'subtype_xmlid': 'mail.mt_note'
})

# Шаг 2: Прямая запись в mail.message принимает текст без экранирования!
clean_html = "Telemetry Status: 100% Проверено (Подпись Валидирована)"
models.execute_kw(db, uid, pwd, 'mail.message', 'write', [
[msg_id], {'body': clean_html}
])

Устранение ловушки экранирования тегов Markup() в XML-RPC


В корпоративных проектах интеграции и внешние AI-агенты обращаются к Odoo по протоколу XML-RPC или JSON-RPC. Распространенной проблемой является искажение форматированных сообщений в лентах обсуждений Discuss и карточках Chatter.

Двойное экранирование функцией tools.plaintext2html()

При выполнении вызова models.execute_kw(..., 'message_post', ...) через XML-RPC все строки упаковываются в стандартные теги <string>. Объект Python markupsafe.Markup невозможно передать по сетевому протоколу.

Когда внутренний метод Odoo mail.thread.message_post принимает строку:

isinstance(body, markupsafe.Markup) # Всегда возвращает False!

Поскольку тип объекта является обычной строкой str, Odoo расценивает тело как потенциально опасный сырой текст и прогоняет его через функцию tools.plaintext2html(). Функция преобразует все символы < в &lt;, а > в &gt;. В результате пользователь видит в ленте карточки не форматированную верстку или статусную плашку, а исходный HTML-код с экранированными тегами:

&lt;p&gt;&lt;b&gt;Аудит завершен успешно&lt;/b&gt;&lt;/p&gt;

Шаблон двухшаговой записи (Two-Step Write)

Чтобы обойти данное ограничение без модификации исходного кода Odoo, внешние скрипты используют двухшаговый алгоритм публикации:

  1. Вызывается штатный метод message_post с временным текстовым заполнителем, создавая системную запись mail.message с параметром subtype_xmlid='mail.mt_note' (что гарантирует отсутствие ложных email-рассылок клиентам).
  2. Выполняется прямой вызов write() к только что созданной записи в таблице mail.message по полю body (fields.Html).

Поскольку прямая запись в поля типа fields.Html минует вызов plaintext2html() и использует штатный санитайзер tools.html_sanitize(), HTML-разметка сохраняется в исходном виде и визуализируется корректно.

Защита соединений базы данных при массовом приеме CSP-телеметрии


Современные стандарты безопасности требуют обязательной настройки заголовков Content Security Policy (CSP). При обнаружении несанкционированного скрипта браузер отправляет фоновый отчет на адрес /csp-violation.

Исчерпание пула подключений к базе данных под трафиком ботов

Во время внешнего сканирования на уязвимости или при индексации страниц поисковыми роботами сотни клиентских браузеров могут генерировать обращения к эндпоинту /csp-violation одновременно.

Если контроллер открывает новое соединение с базой на каждый входящий запрос:

with request.env.registry.cursor() as cr:
env = api.Environment(cr, SUPERUSER_ID, {})
env['dlab.csp.violation'].create(report_data)

Каждый HTTP-запрос занимает активный слот в пуле PostgreSQL. При нагрузке в 500 запросов в секунду пул соединений (db_maxconn = 64) мгновенно забивается. Реальные пользователи и кассовые операции падают с критической ошибкой psycopg2.OperationalError: FATAL: remaining connection slots are reserved for non-replication superuser connections. К тому же за несколько месяцев база разрастается сотнями тысяч мусорных записей.

Троттлинг в памяти и автоматическая очистка через @api.autovacuum

Мы применяем комплексный механизм защиты:

  1. Скользящее окно троттлинга в оперативной памяти использует потокобезопасную структуру для отслеживания частоты запросов с каждой подсети /24. Если с одного IP приходит более 10 отчетов за 60 секунд, контроллер моментально отдает HTTP 429 Too Many Requests на уровне WSGI без обращения к PostgreSQL и без открытия транзакций.
  2. Автоматическая очистка по регламенту реализует штатный метод @api.autovacuum, привязанный к настройке компании res.company.x_csp_retention_days (по умолчанию 7 дней). Ночные крон-задачи удаляют устаревшие записи пачками с использованием индексов.
from odoo import models, fields, api
from datetime import timedelta

class DlabCspViolation(models.Model):
_name = 'dlab.csp.violation'
_description = 'Телеметрия нарушений CSP с троттлингом'
_order = 'create_date desc'

blocked_uri = fields.Char(string="Заблокированный URI", size=512, index=True)
violated_directive = fields.Char(string="Нарушенная директива", size=128)
client_ip = fields.Char(string="IP-адрес клиента", size=45, index=True)

@api.autovacuum
def _gc_csp_violations(self):
"""Удаление записей телеметрии старше установленного срока хранения."""
retention_days = int(
self.env['ir.config_parameter'].sudo().get_param('dlab.csp_retention_days', 7)
)
cutoff_date = fields.Datetime.now() - timedelta(days=retention_days)

# Пакетное удаление исключает долговременные блокировки таблицы
self.search([('create_date', '<', cutoff_date)], limit=5000).unlink()

Сквозная SQL-атрибуция от cookie FPID до коммерческой выручки


Финальным этапом построения инфраструктуры телеметрии является связывание разрозненных потоков событий в единый отчет о доходности контента. После блокировки сторонних файлов cookie (3rd-party cookies) в современных браузерах корпоративные системы должны опираться на собственные серверные идентификаторы (first-party identifiers).

Наша архитектура связывает три независимых источника данных в PostgreSQL:

  1. Первичный идентификатор dlab_fpid представляет собой криптографический, защищенный HTTP-only cookie, генерируемый при первом визите пользователя и фиксируемый во всех веб-сессиях.
  2. Meta Conversions API (серверный CAPI) передает события покупок и заявок напрямую от сервера к серверу по HTTPS с хэшированными параметрами клиентов.
  3. Google Ad Manager SOAP API (v202602) формирует ежедневные отчеты о выручке с выгрузкой eCPM, показов и таргетинга через ReportDownloader.DownloadReportToFile. (Важно: в API v202602 скачиваемый поток по умолчанию сжат gzip и распаковывается через gzip.open(..., mode='rt', encoding='utf-8')).
-- Представление аналитики: сквозная атрибуция доходности контента
CREATE OR REPLACE VIEW dlab_content_attribution_report AS
SELECT
bp.id AS post_id,
bp.name AS article_title,
bp.author_id,
COUNT(DISTINCT sess.fpid) AS unique_readers,
COALESCE(SUM(gam.impressions), 0) AS total_ad_impressions,
COALESCE(SUM(gam.revenue_cpm), 0.0) AS direct_ad_revenue,
COALESCE(SUM(capi.conversion_value), 0.0) AS downstream_conversions,
(COALESCE(SUM(gam.revenue_cpm), 0.0) + COALESCE(SUM(capi.conversion_value), 0.0)) AS total_economic_yield
FROM
blog_post bp
LEFT JOIN
dlab_web_session sess ON sess.post_id = bp.id
LEFT JOIN
dlab_gam_daily_revenue gam ON gam.post_id = bp.id
AND gam.date >= CURRENT_DATE - INTERVAL '30 days'
LEFT JOIN
dlab_meta_capi_event capi ON capi.fpid = sess.fpid
AND capi.event_name = 'Purchase'
GROUP BY
bp.id, bp.name, bp.author_id;

Рост точности атрибуции: серверный CAPI + FPID против клиентского пикселя

Когда не нужно усложнять стек телеметрии


Зрелый системный подход требует четкого понимания границ применимости технологии:

  1. Компании, поддерживающие корпоративные сайты-визитки с посещаемостью до 500 человек в день и принимающие менее 10 вебхуков в неделю, не нуждаются в развертывании очередей Pub/Sub, ведении журналов идемпотентности и настройке серверного CAPI. Стандартной веб-аналитики и синхронных скриптов вполне достаточно.
  2. Закрытые монолитные контуры с гарантированной связью, где все поставщики и потребители вебхуков находятся внутри единого защищенного периметра VPC с задержками менее миллисекунды и доступностью 99.999% (например, внутренние микросервисы на gRPC), не требуют внедрения очередей с длительным хранением.
  3. Синхронные формы оплаты с редиректом 3D-Secure, где оформление заказа требует мгновенного отображения банковского окна подтверждения оплаты, нельзя переводить в асинхронную очередь Pub/Sub, так как пауза на экране пользователя приведет к брошенным корзинам.

При проектировании высоконагруженных конвейеров важно четко разделять ответственность транспортного и прикладного уровней. Спецификация JSON-RPC 2.0 требует возвращать код HTTP 200 при любом успешном сетевом соединении, упаковывая детали сбоя внутрь JSON-ответа, тогда как облачные брокеры очередей уровня GCP Pub/Sub опираются исключительно на HTTP-коды 500 и 503 для запуска повторной доставки. Аналогичным образом двухшаговая запись в mail.message.body при работе через XML-RPC, принудительный вызов googletag.destroySlots() при SPA-переходах в OWL и отсечение паразитных запросов к /csp-violation в оперативной памяти процесса Python до выделения курсора из пула защищают и клиентский интерфейс, и СУБД PostgreSQL от деградации под пиковой нагрузкой.

Управляемая ERP, омниканальный ИИ и White-Label поставка dlab.md Engineering Advisory

Внедрение Odoo 19, MCP-агентов и серверной атрибуции за 6 недель

Масштабируете собственный бизнес на едином контуре CRM, ERP и диалогового ИИ или ищете надёжного White-Label инженерного партнёра для сложных проектов Odoo 19 и Meta CAPI в Европе — мы поставляем управляемую инфраструктуру с фиксированным бюджетом и сроком.