Главная/Статьи/Работа с SFTP в 1С-Битрикс: загрузка и скачивание файлов

Работа с SFTP в 1С-Битрикс: загрузка и скачивание файлов

Использование встроенного класса Bitrix\Sale\TradingPlatform\Sftp для работы с SFTP-серверами. Подключение, загрузка, скачивание файлов.

ДМ
Дмитрий Мещеряков
📅 28 мая 2022 г.📖 8 мин чтения

SFTP выглядит как технология из прошлого ровно до тех пор, пока не начинаешь работать с маркетплейсами, банками и крупными поставщиками. Там это по-прежнему основной способ обмена: прайс раз в сутки, остатки раз в час, реестр платежей по расписанию.

В модуле sale есть встроенный класс, который закрывает базовые операции без установки библиотек. Разберём его — и то, что превращает работающий скрипт импорта в надёжный: атомарность, идемпотентность и внятная диагностика.

Введение

В модуле sale Битрикса есть готовый класс для работы с SFTP — Bitrix\Sale\TradingPlatform\Sftp. Он позволяет:

  • Подключаться к SFTP-серверам
  • Загружать и скачивать файлы
  • Получать списки файлов и их размеры

Это удобно для интеграций с маркетплейсами (WB, Ozon), банками и другими системами, которые обмениваются файлами через SFTP.

Базовое использование

php
<?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
<?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
<?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
<?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
<?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
<?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 стоит, когда нужна аутентификация по ключу, проверка отпечатка хоста или тонкая работа с правами и атрибутами файлов.

А вот что действительно определяет надёжность интеграции, и к выбору библиотеки отношения не имеет:

  1. Учётные данные вне репозитория — конфигурация или переменные окружения.
  2. Явный признак готовности файла — иначе рано или поздно прочитаете половину.
  3. Журнал обработанных файлов — защита от повторной обработки.
  4. Скачивание через временное имя с переименованием после успеха.
  5. Проверка данных на вменяемость перед применением к каталогу.
  6. Запуск из cron с блокировкой, а не агентом на хите.
  7. Уведомление живому человеку при сбое. Молча упавший ночной обмен — это устаревшие остатки и заказы на то, чего нет на складе; узнавать об этом от покупателей дорого.
🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.