Привет, Хабр! Это снова Алиса из 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. Повторные события не создают дубли, а история уведомлений и статусы доставки доступны для поддержки и менеджеров.

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