惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

F
Fortinet All Blogs
MyScale Blog
MyScale Blog
Microsoft Security Blog
Microsoft Security Blog
量子位
B
Blog
aimingoo的专栏
aimingoo的专栏
Apple Machine Learning Research
Apple Machine Learning Research
阮一峰的网络日志
阮一峰的网络日志
The GitHub Blog
The GitHub Blog
T
The Exploit Database - CXSecurity.com
N
News | PayPal Newsroom
Cloudbric
Cloudbric
A
About on SuperTechFans
AI
AI
Hacker News: Ask HN
Hacker News: Ask HN
S
Schneier on Security
Recent Commits to openclaw:main
Recent Commits to openclaw:main
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
C
Cyber Attacks, Cyber Crime and Cyber Security
L
LINUX DO - 最新话题
T
The Blog of Author Tim Ferriss
Simon Willison's Weblog
Simon Willison's Weblog
有赞技术团队
有赞技术团队
H
Heimdal Security Blog
J
Java Code Geeks
大猫的无限游戏
大猫的无限游戏
D
Docker
Security Archives - TechRepublic
Security Archives - TechRepublic
N
News and Events Feed by Topic
IT之家
IT之家
Know Your Adversary
Know Your Adversary
N
Netflix TechBlog - Medium
T
Tailwind CSS Blog
B
Blog RSS Feed
C
Cybersecurity and Infrastructure Security Agency CISA
C
Cisco Blogs
博客园 - 叶小钗
美团技术团队
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
H
Hackread – Cybersecurity News, Data Breaches, AI and More
L
LangChain Blog
The Hacker News
The Hacker News
Y
Y Combinator Blog
I
Intezer
The Register - Security
The Register - Security
F
Full Disclosure
V
V2EX
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Last Week in AI
Last Week in AI
Martin Fowler
Martin Fowler

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Bitrix и Laravel: веб-хуки, ERP и все-все-все (часть 5)
Алиса Романова · 2026-05-21 · via Все публикации подряд на Хабре

20 мин

9.1K

Привет, Хабр! Это снова Алиса из KISLOROD. В прошлых частях мы вынесли из Битрикса каталог, корзину, цены и чекаут. Но в любом ecommerce-проекте есть еще одна зона турбулентности — интеграции.

Платежки, ERP, CRM, доставки, SMS, веб-хуки — все это любит тормозить, дублировать запросы и внезапно падать в самый неподходящий момент. Если держать такие вызовы внутри чекаута или админки, проект быстро начинает жить по SLA внешних сервисов.

В этой части разбираем Integration Hub: очереди, веб-хуки, DLQ, идемпотентность и отдельный контур для интеграций, который не блокирует пользователей и не тянет за собой весь чекаут.

Часть 1

Часть 2

Часть 3

Часть 4

Integration Hub: единая точка для ERP, CRM, платежей и доставок

Интеграции — это место, где ecommerce-проект обычно начинает жить по чужому расписанию. Платежки отвечают когда захотят, ERP внезапно недоступна, доставка присылает вебхуки дважды, CRM требует «еще одно обязательное поле». Если все это висит внутри чекаута или админки, проблемы одной системы быстро становятся проблемами всего проекта.

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

Схема здесь довольно простая.

Из внутренних событий вроде order.created, order.updated, stock.changed или price.changed рождаются задания: создать платеж, отправить заказ в ERP, завести отгрузку, отправить уведомление.

Дальше подключаются коннекторы — небольшие адаптеры под конкретные сервисы. У каждого свои таймауты, rate-limit, подписи, форматы адресов и маппинг полей. Платежка не знает про ERP, ERP — про доставку, а Integration Hub держит все это в одном контуре.

Обратная сторона — входящие вебхуки. Платежные системы, доставки и CRM шлют свои уведомления, а Laravel проверяет подпись, убирает дубли и публикует внутренние события integration.event.*.

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

Модель данных: задачи, попытки, DLQ

Держим единый пул задач с явным состоянием, счетчиком попыток и последней ошибкой. История попыток хранится отдельно — для аудита и разбора проблем. Задачи атомарные, одна задача — один вызов внешнего API.

Скрытый текст
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('ih_tasks', function (Blueprint $t): void {
            $t->id();
            $t->string('type', 48);         // payment.create, shipment.create, erp.push_order, crm.create_deal ...
            $t->string('connector', 48);    // acmepay, sdek, boxberry, onec, amo
            $t->uuid('correlation_id');     // связываем с order_uuid / request_id
            $t->json('payload');            // нормализованные поля
            $t->string('status', 16)->default('pending'); // pending|processing|done|failed|dlq
            $t->unsignedSmallInteger('attempts')->default(0);
            $t->timestamp('available_at');  // когда можно брать в работу (для бэкоффа)
            $t->text('last_error')->nullable();
            $t->timestamps();
            $t->index(['status', 'available_at']);
            $t->index(['connector', 'status']);
            $t->index(['correlation_id']);
        });

        Schema::create('ih_attempts', function (Blueprint $t): void {
            $t->id();
            $t->foreignId('task_id')->constrained('ih_tasks')->cascadeOnDelete();
            $t->unsignedSmallInteger('number');
            $t->unsignedInteger('duration_ms');
            $t->unsignedSmallInteger('http_code')->nullable();
            $t->text('error')->nullable();
            $t->json('request')->nullable();
            $t->json('response')->nullable();
            $t->timestamp('created_at');
            $t->index(['task_id']);
        });

        // Входящие webhook-события (идемпотентность)
        Schema::create('ih_inbox', function (Blueprint $t): void {
            $t->id();
            $t->string('source', 48);     // acmepay, sdek, ...
            $t->string('event', 64);      // payment.succeeded, shipment.tracking_updated ...
            $t->string('external_id', 128);
            $t->uuid('correlation_id')->nullable();
            $t->json('payload');
            $t->timestamp('received_at');
            $t->unique(['source','external_id']); // защита от дублей
            $t->index(['source','event']);
        });
    }
};

Диспетчер задач: очередь, бэкофф, DLQ

Воркер забирает батч доступных задач, учитывает rate-limit конкретного коннектора, вызывает адаптер и фиксирует результат попытки.

На временных ошибках задача уходит в retry с бэкоффом. На непоправимой ошибке получает статус failed. Если лимит ретраев исчерпан, то задача отправляется в DLQ:

Скрытый текст
<?php

namespace App\Integration;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;

final class RunTasks extends Command
{
    protected $signature = 'ih:run {connector?} {--limit=100}';
    protected $description = 'Выполняет исходящие задачи интеграций';

    public function handle(): int
    {
        $connectorFilter = (string) $this->argument('connector');
        $limit = (int) $this->option('limit');

        $q = DB::table('ih_tasks')
            ->where('status', 'pending')
            ->where('available_at', '<=', now())
            ->orderBy('available_at')
            ->limit($limit);

        if ($connectorFilter !== '') {
            $q->where('connector', $connectorFilter);
        }

        $tasks = $q->get();
        if ($tasks->isEmpty()) {
            usleep(200_000);
            return self::SUCCESS;
        }

        foreach ($tasks as $t) {
            $connector = ConnectorRegistry::make((string) $t->connector);
            if (! $connector->acquireSlot()) {
                // достигли rate-limit - отложим немного
                DB::table('ih_tasks')->where('id', $t->id)->update([
                    'available_at' => now()->addSeconds(5),
                ]);
                continue;
            }

            DB::table('ih_tasks')->where('id', $t->id)->update([
                'status' => 'processing',
                'updated_at' => now(),
            ]);

            $started = \microtime(true);

            try {
                $reqRes = $connector->send((string) $t->type, \json_decode((string) $t->payload, true));

                $dur = (int) \round((\microtime(true) - $started) * 1000);
                $this->attempt($t->id, $t->attempts + 1, $dur, $reqRes['code'] ?? null, null, $reqRes['request'] ?? null, $reqRes['response'] ?? null);

                DB::table('ih_tasks')->where('id', $t->id)->update([
                    'status' => 'done',
                    'attempts' => $t->attempts + 1,
                    'updated_at' => now(),
                ]);
            } catch (Throwable $e) {
                $dur = (int) \round((\microtime(true) - $started) * 1000);
                $this->attempt($t->id, $t->attempts + 1, $dur, null, $e->getMessage(), null, null);

                $next = $this->backoff((int) $t->attempts);
                $newStatus = ($t->attempts + 1) >= 8 ? 'dlq' : 'pending';

                DB::table('ih_tasks')->where('id', $t->id)->update([
                    'status'       => $newStatus,
                    'attempts'     => $t->attempts + 1,
                    'last_error'   => $e->getMessage(),
                    'available_at' => now()->addSeconds($next),
                    'updated_at'   => now(),
                ]);

                Log::warning('ih task failed', ['id' => $t->id, 'e' => $e->getMessage()]);
            }
        }

        return self::SUCCESS;
    }

    private function attempt(int $taskId, int $num, int $ms, ?int $code, ?string $err, ?array $req, ?array $res): void
    {
        DB::table('ih_attempts')->insert([
            'task_id'     => $taskId,
            'number'      => $num,
            'duration_ms' => $ms,
            'http_code'   => $code,
            'error'       => $err,
            'request'     => $req ? \json_encode($req, \JSON_UNESCAPED_UNICODE) : null,
            'response'    => $res ? \json_encode($res, \JSON_UNESCAPED_UNICODE) : null,
            'created_at'  => now(),
        ]);
    }

    private function backoff(int $attempts): int
    {
        return min(300, 2 ** max(0, $attempts)); // 1,2,4,... до 5 минут
    }
}

Реестр коннекторов и интерфейс

Каждый коннектор реализует единый интерфейс: имя, попытка занять слот с учетом rate-limit и отправка действия во внешний сервис.

Скрытый текст
<?php

namespace App\Integration;

interface Connector
{
    public function name(): string;

    /** Rate-limit: можно ли сейчас отправлять запрос */
    public function acquireSlot(): bool;

    /**
     * @param array<string,mixed> $payload
     * @return array{code?:int, request?:array, response?:array}
     */
    public function send(string $type, array $payload): array;
}

Реестр:

<?php

namespace App\Integration;

final class ConnectorRegistry
{
    public static function make(string $name): Connector
    {
        return match ($name) {
            'acmepay' => app(\App\Integration\Connectors\AcmePay::class),
            'sdek' => app(\App\Integration\Connectors\Sdek::class),
            'onec' => app(\App\Integration\Connectors\OneC::class),
            'amo' => app(\App\Integration\Connectors\AmoCrm::class),
            default => throw new \RuntimeException("Unknown connector {$name}"),
        };
    }
}

Пример: платежный коннектор с подписью и вебхуком

Допустим, у нас есть платежный провайдер AcmePay, где мы создаем платеж через API провайдера и принимаем веб-хук со статусом оплаты.

При создании платежа коннектор собирает запрос, подписывает его и отправляет во внешний API. В ответ получает идентификатор платежа и ссылку на оплату.

После оплаты AcmePay отправляет вебхук. Laravel проверяет подпись, убирает дубли и публикует внутреннее событие об изменении статуса платежа.

Скрытый текст
<?php

namespace App\Integration\Connectors;

use App\Integration\Connector;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\RateLimiter;

final class AcmePay implements Connector
{
    public function name(): string { return 'acmepay'; }

    public function acquireSlot(): bool
    {
        $key = 'rate:acmepay:' . date('YmdHi');
        return RateLimiter::attempt($key, $perMinute = 300, static function (): void {});
    }

    public function send(string $type, array $payload): array
    {
        if ($type !== 'payment.create') {
            throw new \RuntimeException('Unsupported type: ' . $type);
        }

        $req = [
            'amount'   => $payload['amount'],
            'currency' => $payload['currency'],
            'description' => $payload['description'] ?? '',
            'metadata' => ['order_uuid' => $payload['order_uuid']],
            'success_url' => $payload['success_url'],
            'fail_url'    => $payload['fail_url'],
        ];

        $res = Http::timeout(2.5)
            ->withToken((string) env('ACMEPAY_TOKEN'))
            ->post((string) env('ACMEPAY_URL', 'https://api.acmepay.io/v1/payments'), $req);

        if (! $res->successful()) {
            throw new \RuntimeException('AcmePay ' . $res->status() . ': ' . $res->body());
        }

        return [
            'code'     => $res->status(),
            'request'  => $req,
            'response' => $res->json(),
        ];
    }
}

Вебхук-контроллер с валидацией подписи и идемпотентностью:

Скрытый текст
<?php

namespace App\Http\Controllers\Webhook;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

final class AcmePayWebhook
{
    public function handle(Request $r): JsonResponse
    {
        $sig = (string) $r->header('X-Acme-Signature', '');
        $raw = (string) $r->getContent();
        $secret = (string) env('ACMEPAY_WEBHOOK_SECRET');

        $expected = \hash_hmac('sha256', $raw, $secret);
        if (! \hash_equals($expected, $sig)) {
            return response()->json(['ok' => false], 401);
        }

        $payload = $r->json()->all();
        $externalId = (string) ($payload['id'] ?? '');
        $event      = (string) ($payload['event'] ?? '');

        // идемпотентность
        try {
            DB::table('ih_inbox')->insert([
                'source' => 'acmepay',
                'event' => $event,
                'external_id' => $externalId,
                'correlation_id' => $payload['metadata']['order_uuid'] ?? null,
                'payload' => \json_encode($payload, \JSON_UNESCAPED_UNICODE),
                'received_at' => now(),
            ]);
        } catch (\Throwable) {
            return response()->json(['ok' => true]); // дубль
        }

        // публикуем внутреннее событие (Redis Streams) для ордер-саги
        $rds = new \Redis();
        $rds->connect('127.0.0.1', 6379);
        $rds->xAdd('integration.events', '*', [
            'source' => 'acmepay',
            'event' => $event,
            'external_id' => $externalId,
            'correlation_id' => $payload['metadata']['order_uuid'] ?? '',
            'payload_json' => \json_encode($payload, \JSON_UNESCAPED_UNICODE),
        ]);

        return response()->json(['ok' => true]);
    }
}

Маршрут:

Route::post('/webhooks/acmepay', [\App\Http\Controllers\Webhook\AcmePayWebhook::class, 'handle']);

Пример: доставка через агрегатор служб

Коннектор-агрегатор закрывает различия между SDEK, Boxberry, DHL и другими службами. Для нашей системы это одна задача shipment.create, а внутри коннектор уже приводит адрес, тариф, габариты и состав отправления к формату нужного провайдера.

Скрытый текст
<?php

namespace App\Integration\Connectors;

use App\Integration\Connector;
use Illuminate\Support\Facades\Http;

final class Sdek implements Connector
{
    public function name(): string { return 'sdek'; }

    public function acquireSlot(): bool { return true; }

    public function send(string $type, array $payload): array
    {
        if ($type !== 'shipment.create') {
            throw new \RuntimeException('Unsupported');
        }

        $req = [
            'order_uuid' => $payload['order_uuid'],
            'recipient'  => $payload['customer']['name'],
            'phone'      => $payload['customer']['phone'],
            'address'    => $payload['shipping']['address_line'],
            'items'      => \array_map(static fn ($l) => [
                'name' => $l['title'] ?? $l['sku'],
                'ware_code' => $l['sku'],
                'amount' => (int) \ceil((float) $l['qty']),
            ], $payload['lines']),
        ];

        $res = Http::timeout(2.5)
            ->withHeaders(['Authorization' => 'Bearer ' . env('SDEK_TOKEN')])
            ->post(env('SDEK_URL') . '/orders', $req);

        if (! $res->successful()) {
            throw new \RuntimeException('SDEK ' . $res->status());
        }

        return ['code' => $res->status(), 'request' => $req, 'response' => $res->json()];
    }
}

Обмен с 1С и ERP

Для 1С обычно остаются два сценария.

Первый — классический CommerceML. Integration Hub формирует файл выгрузки, публикует его и принимает входящие изменения через воркеры.

Второй — HTTP JSON. Коннектор OneC получает задачу вроде erp.push_order, отправляет JSON в ERP и обрабатывает ответ.

Есть несколько правил, без которых такие интеграции быстро начинают жить своей жизнью.

  • версионирование: у заказа и его позиций есть version, чтобы старое сообщение не перетерло более новое изменение;

  • идемпотентность: 1С любит присылать дубли, поэтому входящие сообщения проверяются через ih_inbox;

  • трассировка: correlation_id = order_uuid связывает между собой задачи, вебхуки и попытки обработки.

Генерация задач из событий

На стороне Orders Service подписываемся на order.materialized и order.updated. Когда приходит событие, создаем задачи для Integration Hub: платеж, доставка, ERP, CRM и уведомления. Каждая задача дальше обрабатывается своим коннектором и живет в очереди независимо от остальных.

Скрытый текст
<?php

namespace App\Listeners;

use Illuminate\Support\Facades\DB;

final class OnOrderMaterialized
{
    public function handle(\App\Events\OrderMaterialized $e): void
    {
        DB::table('ih_tasks')->insert([
            'type' => 'payment.create',
            'connector' => 'acmepay',
            'correlation_id' => $e->orderUuid,
            'payload' => \json_encode([
                'order_uuid' => $e->orderUuid,
                'amount' => $e->total,
                'currency' => $e->currency,
                'success_url' => \sprintf('https://shop.example.ru/thanks/%s', $e->orderUuid),
                'fail_url' => \sprintf('https://shop.example.ru/error/%s', $e->orderUuid),
                'description' => 'Оплата заказа ' . $e->orderUuid,
            ], \JSON_UNESCAPED_UNICODE),
            'available_at' => now(),
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        DB::table('ih_tasks')->insert([
            'type' => 'shipment.create',
            'connector' => 'sdek',
            'correlation_id' => $e->orderUuid,
            'payload' => \json_encode($e->toArray(), \JSON_UNESCAPED_UNICODE),
            'available_at' => now(),
            'created_at' => now(),
            'updated_at' => now(),
        ]);
    }
}

Контракты: AsyncAPI для входящих событий

Минимальный фрагмент, чтобы зафиксировать смысл нашего внутреннего канала:

Скрытый текст
asyncapi: 2.6.0
info:
  title: Integration Events
  version: 1.0.0
channels:
  integration.events:
    subscribe:
      message:
        oneOf:
          - $ref: '#/components/messages/paymentSucceeded'
          - $ref: '#/components/messages/shipmentTrackingUpdated'
components:
  messages:
    paymentSucceeded:
      name: payment.succeeded
      payload:
        type: object
        required: [source, external_id, correlation_id, amount, currency, ts]
        properties:
          source: { type: string }          # acmepay
          external_id: { type: string }
          correlation_id: { type: string }  # order_uuid
          amount: { type: number }
          currency: { type: string }
          ts: { type: integer }

Админ-инструменты: реплей и разбор DLQ

Интеграции периодически ломаются, это нормальная часть жизни любой ecommerce-системы. Поэтому для Integration Hub делаем отдельные админ-инструменты.

DLQ-страница показывает задачи со статусом dlq и позволяет отправить их на повторную обработку: статус меняется на pending, а available_at — на текущее время.

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

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

Безопасность

Ключи доступа храним в env или секрет-хранилище. Для вебхук-роутов включаем (страшно сказать) белый список IP и проверку подписи.

Все исходящие запросы идут по HTTPS, с таймаутами 2–3 секунды и ограниченным числом ретраев. В payload передаем только данные, которые нужны конкретной интеграции.

Как это стыкуется с Битриксом

Битрикс больше не обращается напрямую к платежкам, доставкам и ERP. Изменения приходят событиями:

  • order.updated — статусы оплаты и доставки, трекинги, внешние номера;

  • внутренние события Битрикса — QR оплаты, ссылки на трекинг и документы в карточке заказа.

Битрикс хранит минимальный каркас заказа в b_sale_*. Интеграции и обмен с внешними системами работают через Integration Hub.

Админка продолжает отвечать быстро, а сбои у внешних сервисов не блокируют чекаут.

Уведомления: письма, SMS, push и мессенджеры из одного сервиса

В классическом Битрикс-проекте уведомления часто находятся прямо внутри обработчиков, то есть создали заказ — ушло письмо, прошла оплата — отправилась SMS. Со временем появляются дубли, разные версии шаблонов и интеграции, которые страшно трогать перед релизом.

В нашей схеме уведомления вынесены в отдельный сервис на Laravel. Он получает события вроде order.created или payment.succeeded, рендерит шаблоны, проверяет настройки пользователя, выбирает канал доставки и отправляет сообщение через нужный коннектор.

Битрикс знает только о событии и истории отправок. SMTP, SMS-провайдеры, пуши и мессенджеры работают отдельно и не участвуют в пользовательском запросе.

Уведомления здесь работают как отдельный контур обработки. Не важно, откуда пришло событие — из оформления заказа, CRM, Битрикса или платежки. Дальше схема всегда одинаковая:

  • любой сервис публикует событие: order.created, payment.succeeded, order.shipped и другие;

  • notifications-сервис получает событие, сопоставляет его с нужной темой уведомления, находит подписчиков и их настройки;

  • шаблон рендерится на основе данных события и пользователя;

  • задачи создаются на отправку с учетом троттлинга, дедупликации и идемпотентности;

  • коннекторы доставляют сообщения в нужные каналы: SMTP, SMS, push или мессенджеры;

  • результат отправки сохраняется в логах, а Битрикс и личный кабинет пользователя могут показать историю уведомлений.

Модель данных

Три таблицы: шаблоны, предпочтения, сообщения.

Скрытый текст
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        // Темы и шаблоны
        Schema::create('ntf_templates', function (Blueprint $t): void {
            $t->id();
            $t->string('topic', 64);         // order_status, order_created, password_reset ...
            $t->string('channel', 16);       // email|sms|push|tg
            $t->string('locale', 8)->default('ru');
            $t->string('subject')->nullable();   // для email
            $t->text('body_html')->nullable();   // Blade
            $t->text('body_text')->nullable();
            $t->timestamps();
            $t->unique(['topic','channel','locale']);
        });

        // Предпочтения пользователя по темам и каналам
        Schema::create('ntf_preferences', function (Blueprint $t): void {
            $t->id();
            $t->unsignedBigInteger('user_id');
            $t->string('topic', 64);
            $t->boolean('email')->default(true);
            $t->boolean('sms')->default(false);
            $t->boolean('push')->default(false);
            $t->boolean('tg')->default(false);
            $t->timestamps();
            $t->unique(['user_id','topic']);
        });

        // Сообщения и их состояние
        Schema::create('ntf_messages', function (Blueprint $t): void {
            $t->id();
            $t->string('channel', 16);
            $t->unsignedBigInteger('user_id')->nullable();
            $t->string('address', 256)->nullable();  // email/телефон/chat_id
            $t->string('topic', 64);
            $t->string('event_key', 128);            // идемпотентность (например order_uuid+topic+channel)
            $t->string('subject')->nullable();
            $t->text('body');                        // fin. тело (html/text)
            $t->string('status', 16)->default('pending'); // pending|sent|failed|dlq
            $t->unsignedSmallInteger('attempts')->default(0);
            $t->timestamp('available_at')->nullable();
            $t->string('provider', 32)->nullable();  // smtp, smsc, firebase, telegram ...
            $t->string('provider_id', 128)->nullable();
            $t->text('last_error')->nullable();
            $t->timestamps();
            $t->unique(['event_key','channel']);     // антидубли
            $t->index(['status','available_at']);
        });
    }
};

Контракты и маршрутизация событий

Notifications-сервис принимает нормализованное событие и формирует сообщения для нужных каналов доставки.

Скрытый текст
<?php

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

final class NotificationsController
{
    public function ingest(Request $r): JsonResponse
    {
        $data = $r->validate([
            'topic' => ['required','string','max:64'],     // например "order_status"
            'event' => ['required','string','max:64'],     // "order.created", "payment.succeeded"
            'event_key' => ['required','string','max:128'],    // для идемпотентности, например UUID заказа
            'user_id' => ['nullable','integer','min:1'],
            'locale' => ['nullable','string','max:8'],
            'payload' => ['required','array'],               // данные для подстановки в шаблон
            'addresses' => ['nullable','array'],               // для гостевых уведомлений
            'addresses.email' => ['nullable','email'],
            'addresses.sms' => ['nullable','string','max:32'],
            'addresses.tg' => ['nullable','string','max:64'],
        ]);

        $locale = $data['locale'] ?? 'ru';
        $userId = $data['user_id'] ?? null;
        $topic = $data['topic'];
        $eventKey = $data['event_key'];
        $payload = $data['payload'];
        $addr = (array) ($data['addresses'] ?? []);

        // Определяем каналы: из предпочтений пользователя или адресов гостя
        $channels = $this->resolveChannels($userId, $topic, $addr);

        // Рендер шаблонов под каждый канал
        $tpls = $this->loadTemplates($topic, $locale, $channels);

        // Создаем сообщения (идемпотентно)
        $created = 0;
        foreach ($channels as $ch => $address) {
            if ($tpls[$ch] === null || $address === null) {
                continue;
            }

            [$subject, $body] = $this->render($tpls[$ch], $payload);

            try {
                DB::table('ntf_messages')->insert([
                    'channel' => $ch,
                    'user_id' => $userId,
                    'address' => $address,
                    'topic' => $topic,
                    'event_key' => $eventKey,
                    'subject' => $subject,
                    'body' => $body,
                    'status' => 'pending',
                    'available_at' => now(),
                    'created_at' => now(),
                    'updated_at' => now(),
                ]);
                $created++;
            } catch (\Throwable) {
                // дубль - пропускаем
            }
        }

        return response()->json(['ok' => true, 'created' => $created]);
    }

    /**
     * @return array{email?:string|null,sms?:string|null,push?:string|null,tg?:string|null}
     */
    private function resolveChannels(?int $userId, string $topic, array $addr): array
    {
        $channels = ['email' => null, 'sms' => null, 'push' => null, 'tg' => null];

        if ($userId) {
            $pref = DB::table('ntf_preferences')->where('user_id', $userId)->where('topic', $topic)->first();
            // адреса пользователя - из вашей users-профильной таблицы/карточки
            $profile = DB::table('svc_users_profile')->where('user_id', $userId)->first();

            if ($pref?->email ?? true) $channels['email'] = $profile->email ?? null;
            if ($pref?->sms   ?? false) $channels['sms']  = $profile->phone ?? null;
            if ($pref?->tg    ?? false) $channels['tg']   = $profile->tg_chat_id ?? null;
            if ($pref?->push  ?? false) $channels['push'] = $profile->push_token ?? null;
        } else {
            // гостевые уведомления - берем из request.addresses
            foreach ($channels as $ch => $_) {
                $channels[$ch] = $addr[$ch] ?? null;
            }
        }

        return $channels;
    }

    /**
     * @param string[] $channels
     * @return array<string,array|null>
     */
    private function loadTemplates(string $topic, string $locale, array $channels): array
    {
        $tpls = [];
        $rows = DB::table('ntf_templates')
            ->where('topic', $topic)
            ->whereIn('channel', array_keys($channels))
            ->where('locale', $locale)
            ->get();

        foreach ($channels as $ch => $_) {
            $tpls[$ch] = $rows->firstWhere('channel', $ch) ? (array) $rows->firstWhere('channel', $ch) : null;
        }

        return $tpls;
    }

    /**
     * @param array{subject?:string|null,body_html?:string|null,body_text?:string|null} $tpl
     * @param array<string,mixed> $payload
     * @return array{0:?string,1:string}
     */
    private function render(array $tpl, array $payload): array
    {
        // Email: предпочитаем HTML, SMS: только текст
        $subject = $tpl['subject'] ?? null;

        $html = $tpl['body_html'] ? \Blade::render($tpl['body_html'], $payload) : null;
        $text = $tpl['body_text'] ? \Blade::render($tpl['body_text'], $payload) : null;

        return [$subject, $html ?: ($text ?: '')];
    }
}

Маршрут приема событий:

Route::post('/api/v1/notifications/ingest', [\App\Http\Controllers\NotificationsController::class, 'ingest'])
    ->middleware('bitrix.jwt');

Рендеринг шаблонов

Пример HTML шаблона "order_status" для e-mail (хранится в ntf_templates.body_html):

<table width="100%" cellpadding="0" cellspacing="0" style="font-family:Arial,sans-serif">
  <tr><td>
    <h2>Заказ № {{ substr($order_uuid, 0, 8) }}: статус обновлен</h2>
    <p>Текущий статус: <strong>{{ $status_name }}</strong></p>
    <p>Сумма к оплате: {{ number_format($total, 2, ',', ' ') }} {{ $currency }}</p>
    <p><a href="{{ $order_url }}">Перейти к заказу</a></p>
  </td></tr>
</table>

Текстовая версия для SMS (body_text):
Заказ {{ substr($order_uuid,0,8) }}: статус {{ $status_name }}. Подробнее: {{ $order_short_url }}

Отправка: воркер, троттлинг и DLQ

Воркер забирает готовые сообщения со статусом pending, учитывает rate-limit провайдера и выполняет отправку.

При ошибке сообщение уходит в retry с экспоненциальным бэкоффом. После достижения лимита попыток сообщение получает статус dlq.

Скрытый текст
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

final class SendNotifications extends Command
{
    protected $signature = 'ntf:run {channel?} {--limit=200}';
    protected $description = 'Отправка уведомлений по каналам';

    public function handle(): int
    {
        $ch = (string) $this->argument('channel');
        $limit = (int) $this->option('limit');

        $q = DB::table('ntf_messages')
            ->where('status', 'pending')
            ->where('available_at', '<=', now())
            ->orderBy('available_at')
            ->limit($limit);

        if ($ch !== '') {
            $q->where('channel', $ch);
        }

        $items = $q->get();
        foreach ($items as $m) {
            $sender = \App\Notifications\Senders\SenderRegistry::make((string) $m->channel);

            if (! $sender->acquireSlot()) {
                // отложим, если нет свободного слота
                DB::table('ntf_messages')->where('id', $m->id)->update([
                    'available_at' => now()->addSeconds(5),
                ]);
                continue;
            }

            try {
                $resp = $sender->send([
                    'address' => (string) $m->address,
                    'subject' => (string) $m->subject,
                    'body'    => (string) $m->body,
                ]);

                DB::table('ntf_messages')->where('id', $m->id)->update([
                    'status'      => 'sent',
                    'attempts'    => $m->attempts + 1,
                    'provider'    => $resp['provider'] ?? $m->provider,
                    'provider_id' => $resp['provider_id'] ?? null,
                    'updated_at'  => now(),
                ]);
            } catch (\Throwable $e) {
                $next = min(300, 2 ** $m->attempts);
                $status = ($m->attempts + 1) >= 8 ? 'dlq' : 'pending';

                DB::table('ntf_messages')->where('id', $m->id)->update([
                    'status'       => $status,
                    'attempts'     => $m->attempts + 1,
                    'last_error'   => $e->getMessage(),
                    'available_at' => now()->addSeconds($next),
                    'updated_at'   => now(),
                ]);
            }
        }

        return self::SUCCESS;
    }
}

Интерфейс отправителей и реестр:

Скрытый текст
<?php

namespace App\Notifications\Senders;

interface Sender
{
    public function acquireSlot(): bool;

    /**
     * @param array{address:string,subject?:string,body:string} $msg
     * @return array{provider?:string,provider_id?:string}
     */
    public function send(array $msg): array;
}
<?php

namespace App\Notifications\Senders;

final class SenderRegistry
{
    public static function make(string $channel): Sender
    {
        return match ($channel) {
            'email' => app(EmailSender::class),
            'sms'   => app(SmsSender::class),
            'push'  => app(PushSender::class),
            'tg'    => app(TelegramSender::class),
            default => throw new \RuntimeException("Unknown channel {$channel}"),
        };
    }
}

Примеры отправителей:

Скрытый текст

Пример SMTP-отправителя:

<?php

namespace App\Notifications\Senders;

use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;

final class EmailSender implements Sender
{
    public function acquireSlot(): bool
    {
        $key = 'rate:smtp:' . date('YmdHi');
        return RateLimiter::attempt($key, 600, static function (): void {});
    }

    public function send(array $msg): array
    {
        $to = $msg['address'];
        $subject = $msg['subject'] ?? '';
        $body = $msg['body'];

        Mail::html($body, static function ($m) use ($to, $subject): void {
            $m->to($to)->subject($subject);
        });

        return ['provider' => 'smtp'];
    }
}

И SMS-отправителя:

<?php

namespace App\Notifications\Senders;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\RateLimiter;

final class SmsSender implements Sender
{
    public function acquireSlot(): bool
    {
        $key = 'rate:sms:' . date('YmdHi');
        return RateLimiter::attempt($key, 300, static function (): void {});
    }

    public function send(array $msg): array
    {
        $to = preg_replace('~\D~', '', $msg['address']);
        $text = \strip_tags($msg['body']);
        $text = \mb_substr($text, 0, 480);

        $res = Http::timeout(2.0)->retry(1, 200)
            ->get((string) env('SMSC_URL'), [
                'login' => env('SMSC_LOGIN'),
                'psw'   => env('SMSC_PASSWORD'),
                'phones'=> $to,
                'mes'   => $text,
                'fmt'   => 3,
            ]);

        if (! $res->ok()) {
            throw new \RuntimeException('sms failed ' . $res->status());
        }

        $id = (string) ($res->json()['id'] ?? '');

        return ['provider' => 'smsc', 'provider_id' => $id];
    }
}

Предпочтения пользователей и лимиты отправки 

Пользователь может отключить отдельный канал для конкретной темы уведомлений. Настройки хранятся в ntf_preferences.

Для массовых рассылок добавляем quiet hours и лимиты. Например, ограничение на количество SMS и push в сутки. Счетчики удобно держать в Redis: ntf:quota:{user}:{channel}:{YYYYMMDD} и проверять перед созданием ntf_messages.

Для юридически значимых уведомлений — чеков, правовой информации, подтверждений — отписку не применяем. Такие шаблоны помечаются флагом is_mandatory.

Связка с Битриксом

Битрикс больше не отправляет SMTP и SMS самостоятельно. Он вызывает POST /api/v1/notifications/ingest и передает topic, event_key, payload, user_id или контакты гостя.

Историю уведомлений по заказу можно показать прямо в админке Битрикса через GET /api/admin/notifications?order_uuid=....

Для предпросмотра шаблонов используем внутренний эндпоинт рендера. Битрикс отправляет payload текущего заказа и показывает результат в модальном окне без реальной отправки сообщения.

Антидубли и идемпотентность

event_key формируем детерминированно. Например: order:{uuid}:order_status:{status_code}.

Повторный вызов для одного и того же события не создаст дубликат благодаря уникальному индексу event_key + channel.

Если уведомление нужно отправить повторно, меняем event_key или добавляем версию.

Что получаем

Уведомления — одна из тех частей ecommerce, которым зачастую внимание уделяют по остаточному принципу. Пока проект небольшой, письмо из обработчика заказа выглядит вполне нормально. Потом появляются SMS, push, маркетинговые цепочки, несколько языков, разные шаблоны для B2B и розницы, требования юристов и внезапные жалобы пользователей на пять одинаковых сообщений подряд.

В этот момент обычно и возникает вопрос: кто вообще отвечает за коммуникацию с пользователем — чекаут, Битрикс, CRM или отдельный сервис?

Наш ответ оказался довольно практичным: уведомления не должны жить внутри бизнес-логики заказа. Это отдельный контур со своими шаблонами, каналами, лимитами, историей и ошибками доставки. Что у нас получилось:

  1. Все уведомления, шаблоны, настройки и история собраны в одном месте.

  2. Отправка больше не влияет на скорость checkout или админки — SMTP, SMS и push работают через очереди.

  3. Повторные события не создают дубли, а история уведомлений и статусы доставки доступны для поддержки и менеджеров.

Продолжение следует...