SFTP выглядит как технология из прошлого ровно до тех пор, пока не начинаешь работать с маркетплейсами, банками и крупными поставщиками. Там это по-прежнему основной способ обмена: прайс раз в сутки, остатки раз в час, реестр платежей по расписанию.
В модуле sale есть встроенный класс, который закрывает базовые операции без установки библиотек. Разберём его — и то, что превращает работающий скрипт импорта в надёжный: атомарность, идемпотентность и внятная диагностика.
Введение
В модуле sale Битрикса есть готовый класс для работы с SFTP — Bitrix\Sale\TradingPlatform\Sftp. Он позволяет:
- Подключаться к SFTP-серверам
- Загружать и скачивать файлы
- Получать списки файлов и их размеры
Это удобно для интеграций с маркетплейсами (WB, Ozon), банками и другими системами, которые обмениваются файлами через SFTP.
Базовое использование
<?php
use Bitrix\Main\Loader;
use Bitrix\Sale\TradingPlatform\Sftp;
Loader::includeModule('sale');
// Параметры — из конфигурации вне репозитория, а не из кода.
// Ниже они инлайном только ради читаемости примера
$config = \Bitrix\Main\Config\Configuration::getValue('sftp_supplier');
// Создание подключения
$sftp = new Sftp(
$config['username'],
$config['password'],
$config['host'],
$config['port']
);
// Установка соединения
$sftp->connect();
// Скачивание файла
$sftp->downloadFile(
'/remote/path/file.csv',
'/local/path/file.csv'
);
// Загрузка файла
$sftp->uploadFile(
'/local/path/export.xml',
'/remote/path/export.xml'
);Встроенный класс не проверяет отпечаток хоста. Это значит, что защиты от подмены сервера (MITM) у соединения нет: если DNS увели или трафик перехватили, ваши учётные данные и данные обмена уедут не туда, а скрипт этого не заметит.
Для обмена с внешним контрагентом по публичной сети это существенно. Если требуется проверка known_hosts или аутентификация по ключу вместо пароля — берите phpseclib: там оба механизма есть. Встроенный класс уместен для доверенного канала (VPN, внутренняя сеть) или когда риск осознанно принят.
Доступные методы
| Метод | Описание |
|---|---|
connect() | Установка соединения |
downloadFile($remote, $local) | Скачивание файла |
uploadFile($local, $remote) | Загрузка файла |
getFilesList($path) | Список файлов в директории |
getFileSize($path) | Размер файла в байтах |
Класс-обёртка с расширенным функционалом
<?php
namespace Local\Integration;
use Bitrix\Main\Loader;
use Bitrix\Sale\TradingPlatform\Sftp;
class SftpClient
{
private Sftp $connection;
private bool $connected = false;
private array $config;
public function __construct(array $config)
{
if (!Loader::includeModule('sale')) {
throw new \RuntimeException('Модуль sale не установлен');
}
$this->config = array_merge([
'host' => '',
'port' => 22,
'username' => '',
'password' => '',
'timeout' => 30,
], $config);
$this->connection = new Sftp(
$this->config['username'],
$this->config['password'],
$this->config['host'],
$this->config['port']
);
}
/**
* Подключение к серверу
*/
public function connect(): self
{
if (!$this->connected) {
$this->connection->connect();
$this->connected = true;
}
return $this;
}
/**
* Скачивание файла
*/
public function download(string $remotePath, string $localPath): bool
{
$this->ensureConnected();
// Создаём директорию для локального файла
$localDir = dirname($localPath);
if (!is_dir($localDir)) {
mkdir($localDir, 0755, true);
}
try {
$this->connection->downloadFile($remotePath, $localPath);
return file_exists($localPath);
} catch (\Throwable $e) {
throw new \RuntimeException(
"Ошибка скачивания {$remotePath}: {$e->getMessage()}"
);
}
}
/**
* Загрузка файла
*/
public function upload(string $localPath, string $remotePath): bool
{
$this->ensureConnected();
if (!file_exists($localPath)) {
throw new \InvalidArgumentException("Файл не найден: {$localPath}");
}
try {
$this->connection->uploadFile($localPath, $remotePath);
return true;
} catch (\Throwable $e) {
throw new \RuntimeException(
"Ошибка загрузки {$localPath}: {$e->getMessage()}"
);
}
}
/**
* Список файлов в директории
*/
public function listFiles(string $remotePath): array
{
$this->ensureConnected();
$files = $this->connection->getFilesList($remotePath);
// Фильтруем . и ..
return array_filter($files, fn($f) => !in_array($f, ['.', '..']));
}
/**
* Размер файла
*/
public function getSize(string $remotePath): int
{
$this->ensureConnected();
return (int) $this->connection->getFileSize($remotePath);
}
/**
* Скачивание всех файлов из директории
*/
public function downloadDirectory(string $remotePath, string $localPath, string $pattern = '*'): array
{
$this->ensureConnected();
$files = $this->listFiles($remotePath);
$downloaded = [];
foreach ($files as $file) {
// Фильтр по паттерну
if ($pattern !== '*' && !fnmatch($pattern, $file)) {
continue;
}
$remoteFile = rtrim($remotePath, '/') . '/' . $file;
$localFile = rtrim($localPath, '/') . '/' . $file;
if ($this->download($remoteFile, $localFile)) {
$downloaded[] = $localFile;
}
}
return $downloaded;
}
/**
* Загрузка всех файлов из локальной директории
*/
public function uploadDirectory(string $localPath, string $remotePath, string $pattern = '*'): array
{
$this->ensureConnected();
$files = glob(rtrim($localPath, '/') . '/' . $pattern);
$uploaded = [];
foreach ($files as $localFile) {
if (!is_file($localFile)) {
continue;
}
$fileName = basename($localFile);
$remoteFile = rtrim($remotePath, '/') . '/' . $fileName;
if ($this->upload($localFile, $remoteFile)) {
$uploaded[] = $remoteFile;
}
}
return $uploaded;
}
/**
* Проверка существования файла
*/
public function exists(string $remotePath): bool
{
try {
$size = $this->getSize($remotePath);
return $size >= 0;
} catch (\Throwable) {
return false;
}
}
/**
* Обеспечение подключения
*/
private function ensureConnected(): void
{
if (!$this->connected) {
$this->connect();
}
}
}Три правила файлового обмена
Прежде чем смотреть примеры — то, что отличает работающий скрипт от надёжного. Все три правила выведены из инцидентов, а не из документации.
Не читайте файл, который прямо сейчас записывают. Поставщик кладёт прайс на 200 мегабайт, ваш агент просыпается в середине загрузки и берёт половину файла. Импорт отрабатывает «успешно» и обнуляет остатки по половине каталога. Признак готовности должен быть явным: либо контрагент выкладывает файл-маркер (prices.xml + prices.ok), либо пишет во временное имя и переименовывает, либо вы проверяете, что размер файла не менялся последние N минут.
Считайте, что один и тот же файл вы получите дважды. Сбой сети на середине скачивания, повторный запуск агента, ручной перезапуск после ошибки — всё это норма. Ведите журнал обработанных файлов (имя + размер + хэш) и пропускайте уже импортированные. Иначе повторный импорт заказов создаёт дубли, которые потом разбирает бухгалтерия.
Скачивайте во временный файл и переименовывайте. Ровно та же логика, что и у контрагента, но с вашей стороны: прерванная закачка не должна оставить в рабочей директории обрезанный файл, который подхватит следующий шаг.
Примеры использования
Импорт прайс-листа от поставщика
<?php
use Local\Integration\SftpClient;
$sftp = new SftpClient([
'host' => 'ftp.supplier.com',
'port' => 22,
'username' => 'shop_import',
'password' => getenv('SUPPLIER_SFTP_PASSWORD'),
]);
try {
// Скачиваем свежий прайс
$localFile = '/upload/import/price_' . date('Y-m-d') . '.csv';
$sftp->download('/export/prices/current.csv', $localFile);
echo "Прайс скачан: {$localFile}\n";
echo "Размер: " . filesize($localFile) . " байт\n";
// Запускаем обработку...
} catch (\Throwable $e) {
echo "Ошибка: {$e->getMessage()}\n";
}Выгрузка заказов на маркетплейс
<?php
use Local\Integration\SftpClient;
// Генерируем XML с заказами
$ordersXml = generateOrdersXml();
$localFile = '/upload/export/orders_' . date('Ymd_His') . '.xml';
file_put_contents($localFile, $ordersXml);
// Загружаем на SFTP
$sftp = new SftpClient([
'host' => 'sftp.marketplace.ru',
'username' => 'shop123',
'password' => getenv('MARKETPLACE_PASSWORD'),
]);
$sftp->upload($localFile, '/inbox/orders/' . basename($localFile));
echo "Заказы выгружены\n";Мониторинг новых файлов
<?php
use Local\Integration\SftpClient;
$sftp = new SftpClient($config);
$remotePath = '/outbox/invoices/';
$localPath = '/upload/invoices/';
// Получаем список уже скачанных файлов
$processedFile = $localPath . '.processed';
$processed = file_exists($processedFile)
? file($processedFile, FILE_IGNORE_NEW_LINES)
: [];
// Скачиваем новые файлы
$files = $sftp->listFiles($remotePath);
foreach ($files as $file) {
if (in_array($file, $processed)) {
continue; // Уже обработан
}
if (!str_ends_with($file, '.pdf')) {
continue; // Только PDF
}
$sftp->download(
$remotePath . $file,
$localPath . $file
);
// Отмечаем как обработанный
file_put_contents($processedFile, $file . "\n", FILE_APPEND);
echo "Скачан: {$file}\n";
}Импорт прайса — это не про SFTP, а про транзакционность. Скачали файл, разобрали, начали обновлять цены — и на середине упали по таймауту. В каталоге теперь половина новых цен и половина старых, и никто, включая вас, не знает, где проходит граница.
Минимальный набор мер: разбор файла отдельно от применения изменений, применение порциями с фиксацией позиции (чтобы продолжить, а не начать заново), и проверка на вменяемость перед применением — если в новом прайсе внезапно на 90% меньше позиций или цены отличаются на порядок, это повод остановиться и позвать человека, а не обновлять каталог. Файл от поставщика ломается регулярно, и автоматика, которая слепо применяет что угодно, однажды выставит в магазине цены с потерянным множителем.
Агент для регулярной синхронизации
<?php
// /local/php_interface/init.php
function SftpSyncAgent(): string
{
$sftp = new \Local\Integration\SftpClient([
'host' => \Bitrix\Main\Config\Option::get('main', 'sftp_host'),
'username' => \Bitrix\Main\Config\Option::get('main', 'sftp_user'),
'password' => \Bitrix\Main\Config\Option::get('main', 'sftp_pass'),
]);
try {
$downloaded = $sftp->downloadDirectory(
'/export/prices/',
'/upload/import/',
'*.csv'
);
if (!empty($downloaded)) {
// Запускаем импорт
foreach ($downloaded as $file) {
// processImportFile($file);
}
}
} catch (\Throwable $e) {
AddMessage2Log("SFTP Sync Error: {$e->getMessage()}", 'sftp');
}
return __FUNCTION__ . '();';
}
// Регистрация агента (каждый час)
CAgent::AddAgent(
'SftpSyncAgent();',
'main',
'N',
3600
);Долгий обмен не место для агента на хите. Агент, выполняющийся при загрузке страницы, ограничен max_execution_time и зависит от наличия посетителей. Скачивание стомегабайтного прайса в такие рамки не укладывается. Файловый обмен запускайте отдельным cron-скриптом из CLI, где лимиты времени другие, а расписание предсказуемо.
И обязательно — блокировка от параллельного запуска (flock по файлу): обмен, длящийся дольше интервала запуска, иначе начнёт накладываться сам на себя, и вы получите два процесса, одновременно пишущих в каталог.
Итоги
Встроенный Bitrix\Sale\TradingPlatform\Sftp полностью закрывает базовый сценарий: подключиться, забрать файл, положить файл. Для типовой интеграции с поставщиком этого достаточно, и лишняя зависимость в проекте не появляется.
Переходить на phpseclib стоит, когда нужна аутентификация по ключу, проверка отпечатка хоста или тонкая работа с правами и атрибутами файлов.
А вот что действительно определяет надёжность интеграции, и к выбору библиотеки отношения не имеет:
- Учётные данные вне репозитория — конфигурация или переменные окружения.
- Явный признак готовности файла — иначе рано или поздно прочитаете половину.
- Журнал обработанных файлов — защита от повторной обработки.
- Скачивание через временное имя с переименованием после успеха.
- Проверка данных на вменяемость перед применением к каталогу.
- Запуск из cron с блокировкой, а не агентом на хите.
- Уведомление живому человеку при сбое. Молча упавший ночной обмен — это устаревшие остатки и заказы на то, чего нет на складе; узнавать об этом от покупателей дорого.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.