Глава 59 · От первого клиента к сервису
Свой B24-клиент: одна строка, все сделки и ядро сервиса
От понятного listMethod для Bitrix24 к собственному ядру: токены, Guzzle, страницы API, два банка, Telegram, отказы и повторные заявки.
Первый запуск готовых файлов: настройка → клиент → вызов → массив
Практическая цель первого захода — запустить учебный клиент на своём компьютере, получить одну понятную карточку и увидеть, куда пришли её поля. Не нужно сразу понимать банк, хранилище операций и очередь. Это отдельная локальная лаборатория: распакованный проект запускается PHP, а не устанавливается в WordPress.
Нужны установленный PHP и терминал, массив и ключ, перебор foreach и объект и вызов метода. Для первого запуска достаточно уметь сохранить файл и выполнить команду. Изучать внутренние интерфейсы до получения результата необязательно.
- bootstrap.php подключает определения; сценарий success выбирает вымышленные успешные ответы
- demoKernel создаёт настроенные объекты; из массива сборки берём клиента crm
- call получает имя метода crm.lead.get и параметр id = 1001
- Локальный транспорт возвращает данные; клиент отдаёт массив ID/TITLE
- Echo и foreach читают массив; никакого нового обращения к CRM при переборе нет
Шаг 1. Скачайте ZIP в конце этой главы и распакуйте его в отдельную папку. Откройте терминал в папке, где лежат bootstrap.php, README.md и каталоги bin/src/public. Проверьте php -v, затем выполните php bin/list-lab.php. Готовый файл должен сообщить: 52 сделки, первая «Учебная сделка 1», последняя «Учебная сделка 52», два запроса страниц, повторный вызов — 52, не 104.
Если вывод совпал, окружение и файлы уже работают. «Could not open input file» обычно означает, что команда запущена не из корня распакованного проекта; ошибка подключения bootstrap.php означает, что потеряна структура папок или скопирован только один файл. Не скачивайте случайные зависимости для исправления: основной проект специально работает без Composer и внешней сети.
Шаг 2. В корне рядом с bootstrap.php создайте first-call.php, вставьте следующий пример целиком и выполните php first-call.php. Папка src должна оставаться на месте. В примере подготовки и выполнения больше не спрятаны в одной длинной записи: каждую переменную можно прочитать сверху вниз.
| Строка или имя | Роль в готовом проекте |
|---|---|
| require __DIR__ . '/bootstrap.php'; | Подключить файл по пути от текущего скрипта. __DIR__ — каталог этого файла, точка соединяет строки пути. Подключение объявляет классы и функцию сборки. |
| IntegrationDemo\demoKernel($scenario) | Вызвать функцию сборки из пространства имён IntegrationDemo. Она создаёт объекты с DemoTransport и заведомо ненастоящими настройками. Это не запрос к вашему порталу. |
| $kernel['crm'] | Достать готовый клиент из массива сборки. В $obj теперь объект с методами, а не данные сделки. |
| $method и $parameters | Выбрать одну операцию и её аргументы. Поле id относится к этому вызову, а не к постоянным настройкам клиента. |
| $item = $obj->call(...) | Выполнить операцию, дождаться её результата и сохранить возвращённый массив. |
| foreach ($item as $field => $value) | На каждой итерации получить очередную пару: например TITLE и Учебный лид. Фигурные скобки задают повторяемый вывод. |
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
$scenario = 'success';
$kernel = IntegrationDemo\demoKernel($scenario);
$obj = $kernel['crm'];
$method = 'crm.lead.get';
$parameters = ['id' => 1001];
$item = $obj->call($method, $parameters);
echo 'Название: ', $item['TITLE'], "\n";
foreach ($item as $field => $value) {
echo $field, ': ', $value, "\n";
}
Ожидаемый результат
Название: Учебный лид
ID: 1001
TITLE: Учебный лид
Call вернул массив полей одной карточки. Echo прочитал конкретное поле TITLE; foreach затем по очереди положил ключ в $field, значение в $value и выполнил вывод. Перебор не отправляет повторных запросов: ответ уже находится в $item. В этой известной фикстуре оба значения строки; произвольный вложенный ответ нельзя целиком выводить таким echo без проверки структуры.
Шаг 3. Поменяйте только id на 1002: локальная фикстура вернёт ID 1002 с тем же учебным названием. Это способ наблюдать передачу аргумента, а не подтверждение существования карточки в реальном Bitrix24. Затем вернитесь к bin/list-lab.php: listMethod отличается тем, что возвращает список карточек. В foreach по такому списку текущая переменная будет целым массивом сделки, из которого читают TITLE.
Параметры конструктора, URL и получение токена не смешиваются с полями одной выборки. В этом старте bootstrap.php намеренно выбирает только DemoTransport; реальный URL или ключ туда вводить не нужно. Позже пример создания клиента раскроет четыре отдельных new и покажет счётчики: создание не запрашивает данные и ещё не читает токен.
| Что открыть после первого успеха | Какой вопрос решает файл |
|---|---|
| bin/list-lab.php | Как настроить выборку и получить 52 строки без ручного цикла страниц в отчёте. |
| src/Clients.php → B24Client | Чем call, oneMethod и listMethod различаются по возвращаемым данным. |
| src/DemoTransport.php | Откуда взялись учебные ответы; почему здесь нет сети и настоящей CRM. |
| bootstrap.php | Где клиент получает API-объект, транспорт и источник токена. Детали читать после простого примера передачи помощника. |
| public/index.php и bin/demo.php | Следующий этап: HTTP-заявка либо CLI-сценарий вызывает общее ядро. Сначала прочитайте переход от одной CRM к ядру. |
Первый результат не требует запуска веб-сервера. Когда захотите проследить мобильное приложение → HTTP → массив → сервис → JSON, используйте команды из раздела запуска: php -S 127.0.0.1:8772 -t public и отдельный POST. Там описаны точные адрес, поля, ошибки и повтор заявки. Не начинайте с подключения Guzzle и настоящего банка: сначала установите границу между работающим вызовом, полученным массивом и его обработкой.
Первая полезная строка: получить все сделки для отчёта
Свой домен принимает заявку, ядро координирует клиентов
Мобильному приложению нужен один понятный запрос. Внутри ядра остаются проверка заявки, разные API и сохранение исхода.
- Вход: JSON заявки
Ключ запроса, SKU, количество и банк проверяются; сумму ядро получает из своего каталога.
- IntakeService и сохранённая попытка
До внешних шагов фиксируется намерение. Тот же ключ и вход позволяют вернуть сохранённый результат.
- Последовательность внутри ядра
Каждый клиент знает свой договор. Следующий шаг зависит от результата предыдущего.
- 1. B24Client → заявка в CRM
createLead создаёт заявку. Для отдельного отчёта listMethod собирает страницы 50 + 2 сделки.
- 2. BankGateway → счёт
Выбран North или South; разные поля адаптеров превращаются в единый Invoice.
- 3. TelegramClient → уведомление
Сообщение отправляется после получения счёта; сбой уведомления не отменяет уже созданный счёт.
- Ответ и состояние в хранилище
completed означает создание счёта и уведомление. bank_unknown требует выяснить исход, а не повторять вслепую.
Учебный проект использует локальные двойники без реальных токенов и банковских запросов. completed не означает «оплачено». После HTTP-запроса данные сохраняет хранилище, а не само существование объекта клиента.
Представьте знакомую задачу: утром нужны все сделки из CRM для отчёта. Вы знаете поля, которые хотите получить. Но API отдаёт данные страницами; нужно собрать адрес, добавить авторизацию, отправить HTTP, разобрать JSON, проверить ошибку, перейти к следующей странице и ничего не продублировать. Если каждый отчёт снова пишет эти действия, исправлять их придётся во многих местах.
Именно для этого нужен собственный клиент. Вы один раз настраиваете объект, а в рабочем коде читаете намерение: $result = $obj->listMethod($arrToReq, 'crm.deal.list');. Идея продолжает авторскую заготовку для Bitrix24: сложная внутренняя работа превращается в короткую понятную операцию. Ниже новая самостоятельная реализация с локальными ответами; реальные ключи не нужны.
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
$obj = IntegrationDemo\demoKernel()['crm'];
$arrToReq = ['select' => ['ID', 'TITLE'], 'order' => ['ID' => 'ASC']];
$result = $obj->listMethod($arrToReq, 'crm.deal.list');
echo 'Сделок: ', count($result), "\n";
echo $result[0]['TITLE'], "\n";
echo $result[array_key_last($result)]['TITLE'], "\n";
Ожидаемый результат
Сделок: 52
Учебная сделка 1
Учебная сделка 52
Скачиваемый пример включает bootstrap.php и src: запускайте его после распаковки всего ZIP. Локальный транспорт возвращает 50 записей, затем ещё 2. Это настоящая работа нашего цикла и массивов, но вымышленные ответы CRM. Фильтрация фикстуры не реализована: этот маленький внешний мир проверяет пагинацию, а не подменяет весь Bitrix24.
Первые три переменные имеют разные роли. $obj — уже настроенный инструмент с методами. $arrToReq — настройки одной выборки: какие поля, какой порядок, какие условия. $result — готовые данные, которые можно перебрать и превратить в HTML, CSV или JSON. Вы меняете условия отчёта, а не переписываете сетевой код. Оператор -> означает обращение к методу конкретного объекта.
Из того же объекта можно вызвать $page = $obj->oneMethod($arrToReq, 'crm.deal.list');. Тогда получите одну страницу: $page['result'] содержит первые50 записей, а $page['next'] подсказывает продолжение. Нужна текущая страница интерфейса — oneMethod. Нужен весь ограниченный список для массовой обработки — listMethod. Выбор понятен в месте вызова; реализация авторизации остаётся общей.
Что объект добавляет к обычным переменным и функциям
Сравнить одну и ту же задачу в трёх вариантах — копирование, нормальные функции с явными зависимостями, настроенный объект — можно в главе «От процедурного к объектному коду». Процедурное решение не обязано копировать HTTP-код: функции также можно вынести в файл, загрузить через Composer и повторно использовать.
Без класса можно написать loadDeals($http, $portal, $token, $filter). Это рабочий вариант. Проблема появляется, когда десять функций должны каждый раз получать согласованные URL, токен, обработчик ошибок и политику повторов. Один вызывающий код передаст токен другого портала, другой забудет тайм-аут, третий обработает только первую страницу.
Объект клиента связывает настройки с действиями. Один экземпляр может обслуживать ваш портал, другой — учебный портал. Их параметры не лежат в общей глобальной переменной и не перезаписывают друг друга. При этом общий алгоритм остаётся в классе. Исправление listMethod сразу действует в отчёте продаж, ночном экспорте и административной странице, если они используют этот класс.
Класс похож на описание инструмента: какие зависимости нужны и какие операции доступны. new создаёт конкретный экземпляр и вызывает конструктор. В данном проекте конструктор только сохраняет зависимости; список сделок он пока не запрашивает. Создание стандартного Guzzle Client также само по себе не выполняет HTTP. Это важная граница: подготовка клиента и выполнение команды — разные события.
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
$transport = new IntegrationDemo\DemoTransport();
$tokens = new IntegrationDemo\DemoTokenProvider('bitrix');
$api = new IntegrationDemo\ApiClient('bitrix', $transport, $tokens);
$obj = new IntegrationDemo\B24Client($api);
echo count($transport->calls()), ':', $tokens->reads, "\n";
$item = $obj->item(1001);
echo count($transport->calls()), ':', $tokens->reads, ':', $item['TITLE'], "\n";
Ожидаемый результат
0:0
1:1:Учебный лид
Первое число — вызовы транспорта, второе — чтения TokenProvider. private ограничивает доступ к внутренней сборке клиента, а public item() предоставляет понятное действие. Конструктор не обязан быть без I/O по правилам языка; это осознанный выбор этого проекта.
Попробуйте. Нужен второй портал для тестов. Почему недостаточно поменять глобальный $token перед вызовом старого объекта?
Разбор решения
Клиент уже получил свои зависимости. Создайте второй экземпляр с другим TokenProvider и транспортной конфигурацией. Так выбор портала виден в сборке приложения, а не зависит от того, какая строка последней переписала глобальное состояние.
Кто кого вызывает: listMethod, oneMethod, транспорт и JSON
- Отчёт вызывает B24Client::listMethod(params, method)
- listMethod задаёт start и вызывает oneMethod
- oneMethod просит ApiClient вызвать API
- ApiClient получает текущий токен у TokenProvider
- Transport возвращает массив envelope
- oneMethod проверяет ошибку, listMethod добавляет result и читает next
- Отчёт получает массив всех элементов в пределах бюджета
| Слой | Что знает | Что возвращает |
|---|---|---|
| B24Client::oneMethod | Форму ответа Bitrix24: result, error, next | Проверенный envelope целиком |
| B24Client::call | Удобный доступ к одному result | Результат операции; тип зависит от метода |
| B24Client::listMethod | Проход next/start и ограничения выборки | Массив записей или исключение, без тихой обрезки |
| B24Client::item / createLead | Названия полей конкретной операции | Проверенный массив карточки / integer ID |
| ApiClient | Какой сервис и TokenProvider использовать | Массив ответа от транспорта |
| Transport | Как доставить запрос: локально или через HTTP | Декодированный envelope; ошибки доставки отдельно |
JSON приходит по сети как текст. После json_decode($body, true, flags: JSON_THROW_ON_ERROR) объекты JSON становятся ассоциативными массивами, а списки — массивами с числовыми индексами. $page['result'][0]['TITLE'] означает: полезный результат → первая запись → название. В нашем DemoTransport фикстура уже является массивом; настоящий Guzzle-адаптер явно выполняет декодирование.
Не всякий HTTP200 означает успех API. Bitrix может вернуть error, Telegram — ok:false. Поэтому проверки делаются в адаптере конкретного сервиса. Не надо заставлять общий HTTP-транспорт угадывать, что result значит у банка и в CRM. В частности, наличие error проверяется array_key_exists: пустая строка тоже остаётся полем ошибки.
Метод oneMethod сохраняет next/total для тех, кому нужна одна страница и её метаданные. call возвращает только result и удобен для одиночной операции. listMethod для отчёта намеренно берёт на себя сбор нескольких страниц. Это три разных договора, а не три случайных названия одной функции.
Как собрать все страницы и не копить старые ответы
В listMethod массив $items = [] создаётся при каждом входе в метод. Он не является свойством клиента. Поэтому два вызова на одном объекте дают два независимых результата. Свойство-накопитель потребовало бы отдельного cleanArr и сделало результат зависимым от истории использования объекта. Ошибка особенно неприятна в длинном воркере, который обслуживает много задач.
Алгоритм использует обычный цикл: запросить страницу, проверить result, добавить элементы foreach, посмотреть next. Если next отсутствует — закончить. Если next не продвигается вперёд — остановиться с ошибкой. Рекурсия здесь не даёт преимуществ: цикл яснее отражает повтор одной операции и удобнее считает бюджеты. Полная реализация находится в src/Clients.php.
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
$kernel = IntegrationDemo\demoKernel();
$obj = $kernel['crm'];
foreach ([1, 2] as $run) {
$rows = $obj->listMethod([], 'crm.deal.list');
echo $run, ': ', count($rows), "\n";
}
echo 'Вызовов: ', count($kernel['transport']->calls()), "\n";
try { $obj->listMethod([], 'crm.deal.list', maxPages: 1); }
catch (LengthException) { echo "Лимит страниц: отчёт не выдан частично\n"; }
Ожидаемый результат
1: 52
2: 52
Вызовов: 4
Лимит страниц: отчёт не выдан частично
Две страницы на выборку означают четыре обращения к локальному транспорту. maxPages и maxItems защищают память и время. Если бюджет закончился, читатель получает понятную ошибку; число записей в отчёте не становится случайно неполным.
Собрать миллион записей в одном массиве дорого по памяти: сложность хранения растёт с числом элементов. Для больших экспортов нужен другой договор — генератор страниц или обработчик каждой страницы, который пишет CSV частями и сохраняет checkpoint. Это отдельный метод вроде iteratePages, а не неожиданное изменение listMethod. Само использование ООП не ускоряет сеть.
Пауза sleep(1) может быть частью простого последовательного CLI-экспорта, но не доказывает соблюдение всех лимитов Bitrix24. Несколько процессов делят общий лимит портала; возможны разные ограничения методов. В проекте повторяется только чтение списка при явном RateLimited: не более трёх попыток страницы и пяти секунд суммарного ожидания. Для тестов Pause записывает секунды без сна. В настоящем воркере BlockingPause вызывает sleep, а длинный retry_after означает перенос задачи, не удержание HTTP-запроса пользователя.
Стабильный order по ID полезен, но offset-пагинация не даёт снимка базы: сделки могут изменяться во время выгрузки. Для важных отчётов продумайте границу периода, повторную сверку, ключевую пагинацию при поддержке API и возобновление после сбоя. Количество страниц и состояние внешней CRM — разные гарантии.
Guzzle через Composer: сохранить удобство клиента, отделить доставку
В авторской заготовке B24 наследовался от GuzzleHttp\Client: это давало знакомый request рядом с собственными методами. Практический выигрыш — всё тот же короткий вызов для читателя. В новой реализации тот же результат достигается композицией: B24 использует свой ApiClient, а адаптер транспорта хранит GuzzleHttp\ClientInterface. Так можно заменить транспорт фикстурой, не переписывая отчёт и не делать все публичные методы общего HTTP-клиента частью договора CRM.
Команда composer require guzzlehttp/guzzle:^7.0 устанавливает зависимость; затем приложение подключает vendor/autoload.php. Composer скачивает код библиотеки, не выполняет вашу операцию CRM. Основной архив этого урока не требует Composer и интернета. Опциональный GuzzleB24Transport лежит отдельно и не подключается bootstrap.php.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/bootstrap.php';
require __DIR__ . '/optional/GuzzleB24Transport.php';
// $tokens implements TokenProvider and reads your protected OAuth store.
// The trusted portal address is configuration, never a mobile request field.
$http = new GuzzleHttp\Client();
$transport = new IntegrationDemo\GuzzleB24Transport(
$http, 'https://portal.example/rest/', allowNetwork: false,
);
// Keep false while studying. No real HTTP is enabled by this example.
// After implementing $tokens, wiring preserves the same client methods:
// $obj = new IntegrationDemo\B24Client(
// new IntegrationDemo\ApiClient('bitrix', $transport, $tokens),
// new IntegrationDemo\BlockingPause(),
// );
Контекстный пример: нужен Composer и файлы проекта, настоящий TokenProvider здесь намеренно не подставлен. Адаптер по умолчанию запрещает сеть. Он показывает структуру подключения и не является готовым OAuth-приложением.
Внутри адаптер вызывает request с тремя аргументами: HTTP-метод, адрес и массив options. В options находятся json, timeout, connect_timeout, http_errors:false и allow_redirects:false. Токен OAuth Bitrix передаётся полем auth внутри JSON; опция Guzzle auth означает HTTP Basic/Digest/NTLM и не заменяет этот протокол. Пользовательские параметры не могут подменить зарезервированное поле auth.
Webhook из первоначальной статьи — отдельная схема, где секрет входит в путь адреса. Нельзя приклеить webhook URL к OAuth-адаптеру и надеяться на автоматическое определение. Конфигурация, TokenProvider и адаптер должны явно знать выбранную схему. Обновление access token требует реализации refresh, защищённого хранения и координации параллельных запросов; интерфейс с названием TokenProvider сам этого не делает.
При ошибке Guzzle сообщение исключения, request/response и previous могут содержать URL или тело с токеном. Адаптер выдаёт безопасное доменное исключение без исходного объекта. В рабочий журнал допускают ограниченный набор полей: провайдер, операция, безопасный код, длительность и correlationId. History middleware и debug полезны в локальном тесте с фиктивными ключами, но сами по себе ничего не маскируют.
Что проверено у транспорта и где заканчивается учебный пример
У опционального Guzzle-адаптера проверен синтаксис и проведён разбор кода. Настоящие CRM-запросы и OAuth refresh не выполнялись; основной запускаемый опыт использует DemoTransport. Не считайте наличие интерфейса доказательством доступности внешнего сервиса. Для подключения реального портала нужен отдельный этап настройки, тесты с MockHandler, затем разрешённый тестовый контур и проверка его договоров.
Ограничение1МБ в исходнике применяется при чтении уже полученного response body. В показанной конфигурации Guzzle мог ранее скачать и буферизовать больше данных; это защита последующего разбора, а не жёсткий предел сетевого трафика/общей памяти. Для недоверенных больших ответов нужен отдельный streaming/sink-вариант с лимитом записи и проверенными тайм-аутами конкретного handler. Не добавляйте stream:true механически: меняются моменты ожидания и обработки ошибок.
Длительный Retry-After не сокращается до удобной одной секунды: если ожидание больше пяти секунд либо передано HTTP-date, адаптер требует отложенной обработки. Пять секунд — учебный бюджет, не лимит Bitrix. Операции записи не повторяются автоматически, потому что потеря ответа может скрывать уже выполненный эффект.
Composer и src: как PHP находит класс
В первом опыте bootstrap.php явно подключает несколько небольших учебных файлов. Это позволяет запустить архив без Composer. В обычном проекте каждый класс чаще лежит в отдельном файле: App\Integration\B24Client — в src/Integration/B24Client.php. Правило PSR-4 "App\\": "src/" сопоставляет префикс пространства имён с каталогом; оставшаяся часть имени превращается в путь.
После изменения autoload в composer.json выполняют composer dump-autoload. Подключение require __DIR__ . '/vendor/autoload.php'; регистрирует загрузчик. Оно не исполняет заранее все классы проекта: когда PHP впервые потребует B24Client, загрузчик найдёт нужный файл. Этот механизм работает и для процедурного кода через autoload.files: перечисленные файлы функций, наоборот, подключаются при загрузке Composer. Подробности и команды — в главе о Composer.
Начинать с собственного клиента полезнее, чем сразу раскладывать один отчёт на десятки каталогов. Но когда появляются два портала, тестовый транспорт и отдельный разработчик банковской интеграции, границы файлов отражают ответственность. HTTP-контроллер не читает файл токена в каждой ветке; bootstrap собирает объекты, а сервис вызывает доступные ему действия.
Получили список — отобразили или передали дальше
Ваш клиент не должен знать, где читатель покажет сделки: в обычной странице, интерфейсе приложения внутри iframe или консольном отчёте. Он возвращает массив. HTML-представление экранирует TITLE, JSON-ответ кодирует массив, обработчик массовой задачи преобразует нужные поля и вызывает следующий клиент. Секреты остаются на сервере; наличие iframe не делает безопасной передачу webhook в JavaScript.
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
$kernel = IntegrationDemo\demoKernel();
$deals = $kernel['crm']->listMethod(['select' => ['ID', 'TITLE']], 'crm.deal.list');
foreach (array_slice($deals, 0, 2) as $deal) {
$text = 'Отчёт: ' . $deal['TITLE'];
$kernel['telegram']->send($text);
echo $text, "\n";
}
echo 'Вызовов: ', count($kernel['transport']->calls()), "\n";
Ожидаемый результат
Отчёт: Учебная сделка 1
Отчёт: Учебная сделка 2
Вызовов: 4
Два чтения страниц и два fake-уведомления. Ограничение до двух записей выбрано специально: пример учит преобразованию, не рекомендует синхронно рассылать каждую сделку в реальный Telegram. Для массовой отправки нужна очередь, лимиты и учёт уже обработанных записей.
Обратите внимание на экономию контекста: вызывающий код видит получить → преобразовать → отправить. Исправление разбора страниц происходит внутри B24Client и сразу помогает этому сценарию, отчёту в iframe и CLI-экспорту. Изменение формата Telegram происходит в его клиенте. Сам порядок двух действий по-прежнему виден в сценарии — объект не должен скрывать внезапную рассылку внутри обычного получения списка.
Следующий шаг: приложение вызывает ваш домен, а не четыре API
Теперь у мобильного приложения появилась кнопка «Оформить». Ему не нужны токен Telegram, webhook CRM и ключи двух банков. Оно отправляет вашему ядру заявку: request_id, sku, quantity, bank. Сервер проверяет пользователя и права в рабочем варианте, валидирует форму сообщения и берёт цену из своего каталога. Отправленная клиентом сумма не является доверенной ценой.
Учебный endpoint public/index.php доступен только на localhost. Он читает ограниченное JSON-тело, проверяет типы и передаёт Intake в один и тот же сервис, который вызывается из CLI. Поэтому сценарий оформления не размножается в мобильном контроллере, веб-форме и консольном импорте. Изменился вход — договор ядра остался тем же.
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
$json = '{"request_id":"mobile-1042","sku":"lesson","quantity":2,"bank":"north"}';
$data = json_decode($json, true, 16, JSON_THROW_ON_ERROR);
$request = IntegrationDemo\Intake::fromArray($data);
echo $request->sku, ': ', $request->quantity, ': ', $request->amountMinor, "\n";
try { IntegrationDemo\Intake::fromArray($data + ['amount_minor' => 1]); }
catch (IntegrationDemo\InvalidInput) { echo "Цена от клиента не принята\n"; }
Ожидаемый результат
lesson: 2: 259800
Цена от клиента не принята
JSON number 2 становится integer, строка "2" отклоняется этим договором. Цена за lesson находится на сервере; amountMinor вычисляется в копейках. Полный HTTP-код дополнительно ограничивает размер, Content-Type, метод и допустимый Origin.
- Приложение → POST JSON на ваш домен
- Контроллер → проверенный Intake
- IntakeService → CRM, затем выбранный BankGateway
- После подтверждённого счёта → TelegramClient
- Хранилище фиксирует результат → JSON приложению
Объект PHP не живёт автоматически между двумя HTTP-запросами. Обычный request заново создаёт kernel и клиентов; локальные переменные исчезают после завершения. Результат предыдущей заявки читается из хранилища. В долгоживущем worker объекты могут сохраняться дольше, и именно поэтому нельзя держать текущего пользователя или накопленные сделки в случайном общем свойстве.
Два банка: одинаковое действие, разные поля и ответы
Вымышленный North принимает amount_minor=259800 и возвращает invoice_id. Вымышленный South принимает строку sum="2598.00" и возвращает bill.reference. Если контроллер знает оба формата, каждый новый банк заставляет править код входа. Интерфейс BankGateway задаёт действие createInvoice(Intake): Invoice; каждый адаптер переводит собственный формат в общий результат.
Строка для South собирается через intdiv и остаток, без float. Это не правило всех банков: настоящая документация может требовать иной масштаб валюты, подпись или тип суммы. Оба договора здесь специально вымышлены, чтобы сравнить адаптеры без иллюзии готового платёжного SDK.
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
$kernel = IntegrationDemo\demoKernel();
foreach (['north', 'south'] as $bank) {
$input = IntegrationDemo\demoInput('demo-' . $bank, $bank);
$result = $kernel['service']->submit(IntegrationDemo\Intake::fromArray($input));
echo $result['bank'], ': ', $result['invoice_id'], ': ', $result['amount_minor'], "\n";
}
Ожидаемый результат
north: N-demo-north: 259800
south: S-demo-south: 259800
Сервис вызывает один договор BankGateway. Выбор адаптера происходит по серверной карте банков. В показанной операции создаётся счёт, а не списываются деньги; completed не означает paid.
Третий разработчик может реализовать новый адаптер по этому интерфейсу, добавить фикстуры и зарегистрировать его в bootstrap. Основной submit не меняется из-за названия поля суммы. Но подключение всё равно требует продуктовой работы: кому банк доступен, какие валюты, как сверять статус, как проверить подпись webhook. Интерфейс убирает лишние связи в коде, не отменяет особенности бизнеса.
Общее журналирование можно разобрать в практическом примере B24 и Telegram с одним трейтом. Даже если B24 и Telegram используют один метод log, смысл ответа у них различен; безопасная форма события должна очищать секреты и персональные данные до записи. Повторное использование кода не является разрешением сохранить весь ответ API в открытый файл.
Сбой посередине: что уже произошло и что можно повторить
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
foreach (['success', 'crm-failure', 'bank-unknown', 'telegram-failure'] as $scenario) {
$kernel = IntegrationDemo\demoKernel($scenario);
$result = $kernel['service']->submit(IntegrationDemo\Intake::fromArray(IntegrationDemo\demoInput()));
echo $scenario, ': ', $result['status'], ', calls=', count($kernel['transport']->calls()), "\n";
}
Ожидаемый результат
success: completed, calls=3
crm-failure: crm_rejected, calls=1
bank-unknown: bank_unknown, calls=2
telegram-failure: notification_pending, calls=3
Считаются реальные вызовы локального транспорта. При отказе CRM банк не вызывается. При неизвестном исходе банка уведомление об успехе не отправляется. Если Telegram отказал, уже созданный счёт сохраняется.
Тайм-аут — это отсутствие подтверждения, а не доказательство отказа. Банк мог создать счёт до обрыва ответа. Если сразу повторить создание с новым ключом, появятся два счёта. В проекте такой исход хранится как bank_unknown и не вызывает автоматический повтор. В рабочем сервисе требуется запрос статуса по устойчивому merchant reference и документированная идемпотентность именно этого провайдера.
Перед возможным эффектом сохраняется намерение: crm_pending, bank_pending, notification_pending. Если процесс погибнет между внешним успехом и локальной записью результата, следующий вызов увидит незавершённое состояние и остановится для сверки. Это безопаснее слепого повтора, но не является полной системой восстановления: отдельный worker и reconciliation здесь не реализованы.
Отказ уведомления не откатывает созданный счёт. Отмена счёта — отдельная бизнес-операция, а не побочный эффект catch. Для Telegram нужно различать явный 429 с retry_after и потерянный ответ после возможной доставки. Простой повтор sendMessage не обладает гарантией exactly-once. В учебном ядре оба случая остаются pending без автоматической отправки; логика очереди должна быть спроектирована отдельно.
Повтор с телефона: память объекта и сохранённое состояние
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
$file = __DIR__ . '/operations.json';
$input = IntegrationDemo\Intake::fromArray(IntegrationDemo\demoInput());
$first = IntegrationDemo\demoKernel('success', new IntegrationDemo\JsonFileStore($file));
$first['service']->submit($input);
$second = IntegrationDemo\demoKernel('success', new IntegrationDemo\JsonFileStore($file));
$result = $second['service']->submit($input);
echo $result['status'], ': replayed=', (int) $result['replayed'], ': calls=', count($second['transport']->calls()), "\n";
Ожидаемый результат
completed: replayed=1: calls=0
Этот самостоятельный пример пишет operations.json рядом с распакованным кодом; повторный запуск продолжает то же учебное состояние. В автоматической проверке весь пример запускается в изолированном временном каталоге. HTTP-проект хранит файл в var вне public.
request_id остаётся одним и тем же при повторе той же операции. Хранилище сравнивает отпечаток нормализованного запроса: sku, quantity, bank. Другая заявка со старым ключом даёт конфликт. Сохранённая сумма берётся из первоначального результата: изменение каталога не должно задним числом менять уже созданный счёт. В рабочем сервисе ключ дополнительно привязывают к владельцу/аккаунту; глобальный ключ без авторизации неприемлем.
JsonFileStore нужен для видимого опыта на одной машине. Блокировка держится во время всего короткого fake-сценария. Если заменить fake настоящим HTTP, такая общая блокировка задержит все заявки: требуется транзакционная БД и отдельное управление выполняющейся операцией, а не flock вокруг сети. Файл также не даёт распределённых гарантий или обещания сохранности при отключении питания. Повреждение JSON считается ошибкой сервера, хранилище не обнуляется.
Для production добавляют уникальный ключ в БД, короткие транзакции, outbox для намерения отправки, worker с lease и повторными попытками, проверку webhook и наблюдаемость. Эти механизмы связаны с транзакциями, webhook и ограничениями API. Сначала важно увидеть, какую проблему решает каждый слой, а не просто перенести список модных названий в проект.
Запуск лаборатории и маршрут чтения исходников
Скачать учебный B24-клиент и ядро сервиса (ZIP)
Нужен PHP8.2+. Распакуйте весь проект. Запустите php bin/list-lab.php, затем php tests/run.php. Сценарии ядра запускаются командой php bin/demo.php success; замените success на two-bank, crm-failure, bank-unknown, telegram-failure или repeat-local. Общий README содержит ожидаемые результаты, команды HTTP и границы демо.
Чтобы увидеть вход от приложения, запустите php -S 127.0.0.1:8772 -t public. GET покажет JSON-подсказку; POST с ней создаст учебную заявку. Во второй отправке того же JSON calls_this_request будет0. Данные сохраняются только в локальном var. В этой лаборатории нет формы, реальных личных данных, банковских ключей и сторонних отправок.
| Что прочитать | На какой вопрос отвечает |
|---|---|
| bin/list-lab.php | Как пользоваться удобным объектом в обычном отчёте? |
| B24Client в src/Clients.php | Что скрывается за одной строкой listMethod? |
| ApiClient + TokenProvider + DemoTransport | Когда нужна конфигурация и кто фактически выполняет вызов? |
| public/index.php | Как JSON из мобильного приложения становится PHP-массивом? |
| src/Application.php | Почему нельзя повторить всю цепочку при сбое последнего шага? |
| src/Stores.php и tests/run.php | Что переживает новый запрос и чем подтверждаются гарантии? |
| optional/GuzzleB24Transport.php | Где подключается настоящий HTTP после разработки авторизации? |
Попробуйте. Добавьте сценарий: CRM создана, South вернул незнакомый объект вместо bill.reference. Нужно ли отправлять Telegram и повторять создание счёта?
Разбор решения
Адаптер должен дать OutcomeUnknown. Сервис сохранит bank_unknown, Telegram не вызывается. В тесте ожидайте два вызова транспорта и ноль новых вызовов при повторе request_id. Для разрешения состояния нужен отдельный запрос статуса, которого в учебном контракте пока нет.
Попробуйте. Отчёт должен выгрузить 100000 сделок. Просто увеличить maxItems до100000?
Разбор решения
Сначала оцените память, время, лимиты и требование целостности. Предпочтите отдельный метод обхода страниц с записью CSV и checkpoint. Не меняйте обещание listMethod незаметно: его удобный массив хорош для ограниченной выборки, не для любого объёма.
Источники и обсуждения
- Авторская идея B24: одна строка получает список
- Bitrix24: crm.deal.list, параметры, next/start и статус метода
- Bitrix24: добавление лида
- Bitrix24: получение лида
- Bitrix24: OAuth и webhook — разные схемы авторизации
- Bitrix24: ограничения REST
- Guzzle: установка Composer
- Guzzle: создание клиента и request()
- Guzzle: параметры запроса
- Guzzle: MockHandler и журнал тестовых запросов
- Composer: PSR-4 и files
- Composer: vendor/autoload.php
- Telegram: sendMessage и ответ Bot API
- Telegram: retry_after
- PHP: интерфейсы объектов
- PHP: конструкторы