Привет, Хабр. На связи Катя Саяпина, менеджер продуктов МТС Exolve. В этой статье разберём, как предотвратить приостановку бизнеса — вовремя пополнять баланс на отправку SMS. С минимальными усилиями соберём свою систему мониторинга расходов на сообщения. Будем фиксировать фактические траты, отслеживать аномалии, строить линейный прогноз и слать себе контрольные SMS.
В статье собрано решение на PHP с Composer, cron и MySQL. Всё максимально просто, чтобы за один вечер развернуть систему на любом сервере без внешних зависимостей.
Система состоит из двух скриптов
Один собирает данные, другой их анализирует и отправляет уведомления по двум триггерам.
Сбор данных
Скрипт запускается каждый час по cron, запрашивает у МТС Exolve количество отправленных SMS за последние 60 минут и текущий баланс и сохраняет всё в базу данных.
Анализ и отправка уведомлений
Запускаются после сбора данных и проверяют историю за последний 31 день. Здесь могут сработать два триггера.
Триггер 1. Баланс на исходе
Система рассчитывает, сколько SMS в среднем отправлялось в сутки за последний 31 день.
Для расчёта берётся медианное количество SMS и умножается на стоимость одного сообщения. В этом примере стоимость сообщения устанавливается вручную — в среднем 3 ₽ за аутентификационное уведомление. Затем сумма на балансе делится на полученное число — так мы видим количество дней, на которое хватит денег.
Если по текущему темпу расходов денег осталось на 5 дней или меньше, отправляется сообщение:
⏳ Баланс 35 000 ₽, хватит на 4 дня. Пополните счёт.
Триггер 2. Всплеск
Система считает количество SMS, отправленных за последние календарные сутки, и сравнивает с медианой за последний 31 день. Если за сутки отправлено в два раза и больше сообщений, чем медианное значение, то подразумевается, что произошёл всплеск. Получаем такое уведомление:
🚨 За сутки отправлено 5 100 SMS — это в 2,3 раза больше обычного. Проверьте активность. Баланса хватит ещё на 1,7 такого дня.
В уведомлении также указывается, на сколько дней хватит текущего баланса, если расход продолжится в подобном темпе.
Установка и запуск проекта
Вся логика разбита по папкам: app/ — сервисы, config/ — зависимости, artisan.php — точка входа для команд. Конфигурация хранится в .env.
Чтобы всё заработало, нужно
-
Установить зависимости через Composer.
-
Задать параметры окружения, такие как доступ к БД и API-ключ.
-
Создать таблицу в MySQL.
-
Настроить два cron-скрипта: один для сбора статистики, второй для анализа и уведомлений.
Под спойлером — структура проекта, установка зависимостей, настройка .env и создание базы данных.
Структура проекта
sms_monitoring/ ├── app/ │ ├── Console/ │ │ └── Command/ │ │ ├── CollectStatsCommand.php # Команда для сбора статистики SMS │ │ └── AnalyzeBalanceCommand.php # Команда для анализа баланса │ ├── DTO/ │ │ └── SmsStatDTO.php # Объект передачи данных для SMS статистики │ ├── Infrastructure/ │ │ └── Database.php # Обёртка над PDO и конфигурация подключения к БД │ ├── Repository/ │ │ └── SmsStatRepository.php # Работа с таблицей статистики │ ├── Service/ │ │ ├── BalanceAnalyzerService.php # Сервис анализа баланса │ │ ├── ExolveApiService.php # Сервис запроса статистики от МТС Exolve │ │ ├── SmsSenderService.php # Сервис для отправки SMS ├── bootstrap/ │ └── app.php # Инициализация контейнера и автозагрузка ├── config/ │ └── container.php # Конфигурация зависимостей PHP-DI ├── cron/ │ ├── collect_stats.sh # Shell-скрипт запуска команды сбора статистики │ ├── analyze_balance.sh # Shell-скрипт запуска команды анализа баланса ├── routes/ │ └── console.php # Регистрация команд (маршруты CLI) ├── artisan.php # Точка входа CLI-приложения ├── .env # Переменные окружения (DB, API_KEY и т.д.) ├── composer.json # Автозагрузка и зависимости ├── database.sql # SQL-дамп структуры базы данных
Создание проекта и установка зависимостей
Начинаем с инициализации проекта и установки минимального набора зависимостей через Composer.
mkdir sms_monitoring cd sms_monitoring composer init composer require vlucas/phpdotenv guzzlehttp/guzzle
Composer.json:
{ "autoload": { "psr-4": { "App\\": "app/" } }, "require": { "vlucas/phpdotenv": "^5.6", "guzzlehttp/guzzle": "^7.9" } }
Генерация автозагрузки:
composer dump-autoload
Создание проекта и установка зависимостей
В корне проекта создаём и заполняем файл .env. Номера телефонов должны состоять только из цифр — без плюсов, пробелов, скобок и других символов.
DB_HOST=ваш хост DB_PORT=ваш порт DB_NAME=sms_monitoring DB_USER=root DB_PASS=root EXOLVE_API_KEY=ваш_ключ EXOLVE_API_URL=https://api.exolve.ru EXOLVE_SENDER=номер_отправителя ALERT_PHONE=номер_получателя
Создание базы данных
Подключаемся к MySQL и создаём таблицу.
CREATE DATABASE sms_monitoring CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE TABLE sms_stats ( id INT AUTO_INCREMENT PRIMARY KEY, date_hour DATETIME NOT NULL UNIQUE, sms_count INT NOT NULL, balance DECIMAL(12, 2) NOT NULL );
Шаг 1. Инициализация приложения
Файл bootstrap/app.php загружает переменные из .env, подключает автозагрузку Composer и собирает мини-контейнер DI. Остальная логика берёт зависимости именно отсюда, так что перенос сервиса сводится к composer install и правильному .env.
<?php require_once __DIR__ . '/../vendor/autoload.php'; use Dotenv\Dotenv; $dotenv = Dotenv::createImmutable(__DIR__ . '/../'); $dotenv->load(); $definitions = require __DIR__ . '/../config/container.php'; $container = []; foreach ($definitions as $key => $factory) { $container[$key] = fn() => $factory($container); } return $container;
Шаг 2. Основные компоненты приложения
Вся бизнес-логика приложения собрана в одном месте и состоит из восьми компонентов: она подключается к базе данных, получает статистику по API, сохраняет данные, анализирует их и отправляет SMS.
Они работают вместе: раз в час данные попадают в базу, раз в день анализируются, и при необходимости отправляется уведомление. Далее — подробно про каждый компонент.
Подключение к БД — Infrastructure/Database.php
Отвечает за соединение с MySQL через PDO, используя параметры из .env. Реализован как Singleton — подключение создаётся один раз и переиспользуется всеми сервисами.
<?php namespace App\Infrastructure; use PDO; use PDOException; use RuntimeException; final class Database { private static ?PDO $pdo = null; private function __construct() { } public static function getConnection(): PDO { if (self::$pdo === null) { $host = getenv('DB_HOST') ?? null; $port = getenv('DB_PORT') ?? '3306'; $db = getenv('DB_NAME') ?? null; $user= getenv('DB_USER') ?? null; $pass = getenv('DB_PASS') ?? null; if (!$host || !$db || !$user) { throw new RuntimeException('Конфигурация базы данных отсутствует в переменных среды.'); } $dsn = sprintf( 'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $db ); try { self::$pdo = new PDO( $dsn, $user, $pass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ] ); } catch (PDOException $e) { throw new RuntimeException('Подключение к базе данных не удалось: ' . $e->getMessage()); } } return self::$pdo; } }
Передача статистики — app/DTO/SmsStatDTO.php
Хранит данные за каждый час: количество отправленных сообщений, баланс, временную метку. Используется для передачи информации между слоями — от API до базы и анализа.
<?php namespace App\DTO; use DateTimeImmutable; class SmsStatDTO { public DateTimeImmutable $dateHour; public int $smsCount; public float $balance; public function __construct(DateTimeImmutable $dateHour, int $smsCount, float $balance) { $this->dateHour = $dateHour; $this->smsCount = $smsCount; $this->balance = $balance; } }
Сохранение и извлечение данных из БД — app/Repository/SmsStatRepository.php
Репозиторий для работы с таблицей sms_stats. Сохраняет полученную статистику и отдаёт данные за последний 31 день. Использует PDO для SQL-запросов, обрабатывает дубли и возвращает данные в удобном для анализа виде.
<?php namespace App\Repository; use App\DTO\SmsStatDTO; use PDO; class SmsStatRepository { private PDO $pdo; public function __construct(PDO $pdo) { $this->pdo = $pdo; } public function save(SmsStatDTO $stat): void { $stmt = $this->pdo->prepare(" INSERT INTO sms_stats (date_hour, sms_count, balance) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE sms_count = VALUES(sms_count), balance = VALUES(balance) "); $stmt->execute([ $stat->dateHour->format('Y-m-d H:00:00'), $stat->smsCount, $stat->balance ]); } public function getLast31DaysStats(): array { $stmt = $this->pdo->query(" SELECT date_hour, sms_count, balance FROM sms_stats WHERE date_hour >= NOW() - INTERVAL 31 DAY "); return $stmt->fetchAll(PDO::FETCH_ASSOC); } }
Получение баланса и статистики отправок по API — app/Service/ExolveApiService.php
Обращается к МТС Exolve по API — получает текущий баланс и количество отправленных SMS. Инкапсулирует работу с HTTP-клиентом Guzzle, автоматически подставляя заголовки и параметры. Обрабатывает ответы от API, проверяя их корректность и отбрасывая исключения при обнаружении ошибок. Используется другими сервисами приложения для получения актуальных данных о состоянии счёта и активности.
<?php namespace App\Service; use GuzzleHttp\Client; use RuntimeException; use stdClass; class ExolveApiService { private Client $client; private string $apiKey; private string $baseUri; private float $timeout; public function __construct() { $this->apiKey = getenv('EXOLVE_API_KEY') ?? ''; $this->baseUri = getenv('EXOLVE_API_URL') ?? 'https://api.exolve.ru'; $this->timeout = (float)(getenv('EXOLVE_API_TIMEOUT') ?? 10.0); if (empty($this->apiKey)) { throw new RuntimeException('EXOLVE_API_KEY не задан в переменных окружения.'); } $this->client = new Client([ 'base_uri' => 'https://api.exolve.ru', 'headers' => [ 'Authorization' => 'Bearer ' . $this->apiKey, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], 'timeout' => 10.0, ]); } public function getBalance(): array { $response = $this->client->post('/finance/v1/GetBalance', [ 'json' => new stdClass() ]); $data = json_decode($response->getBody()->getContents(), true); if (!isset($data['balance'])) { throw new RuntimeException('Неверный ответ от GetBalance'); } return [ 'balance' => (float)$data['balance'], 'credit_limit' => isset($data['credit_limit']) ? (float)$data['credit_limit'] : 0.0, ]; } public function getSmsCount(array $filters = []): int { $response = $this->client->post('/messaging/v1/GetCount', [ 'json' => $filters ?: new stdClass() ]); $data = json_decode($response->getBody()->getContents(), true); if (!isset($data['count'])) { throw new RuntimeException('Неверный ответ от GetCount'); } return (int)$data['count']; } }
Отправка уведомлений — app/Service/SmsSenderService.php
Отправляет уведомления через SMS API при низком балансе или резком росте трафика. Проверяет наличие параметров в окружении и обрабатывает ошибки при отправке.
<?php namespace App\Service; use GuzzleHttp\Client; use Throwable; class SmsSenderService { private Client $client; public function __construct() { $this->client = new Client(); } public function send(string $to, string $message): void { $url = getenv('EXOLVE_API_URL') . '/messaging/v1/SendSMS'; $sender = getenv('EXOLVE_SENDER') ?? null; if (empty($sender)) { echo 'В env отсутствует номер для отправки!'; return; } try { $this->client->post($url, [ 'headers' => [ 'Authorization' => 'Bearer ' . getenv('EXOLVE_API_KEY'), 'Content-Type' => 'application/json', ], 'json' => [ 'number' => getenv('EXOLVE_SENDER'), 'destination' => $to, 'text' => $message ] ]); echo "SMS отправлено: {$to}\n"; } catch (Throwable $e) { echo "Ошибка отправки SMS: {$e->getMessage()}\n"; } } }
Считает медиану и отправляет предупреждения при рисках — app/Service/BalanceAnalyzerService.php
Считает медиану суточных отправок, прогнозирует, на сколько дней хватит баланса. Отправляет SMS, если денег осталось мало или активность резко выросла.
<?php namespace App\Service; use App\Repository\SmsStatRepository; class BalanceAnalyzerService { private SmsStatRepository $repository; private SmsSenderService $smsSender; private const SMS_COST = 3; public function __construct(SmsStatRepository $repository, SmsSenderService $smsSender) { $this->repository = $repository; $this->smsSender = $smsSender; } public function analyze(): void { $stats = $this->repository->getLast31DaysStats(); if (count($stats) < 10) { echo "Недостаточно данных для анализа\n"; return; } // Подсчет количества SMS по дням. $dailySums = []; foreach ($stats as $row) { $day = substr($row['date_hour'], 0, 10); $dailySums[$day] = ($dailySums[$day] ?? 0) + $row['sms_count']; } // Вычисление медианы отправленных SMS за сутки. $dailyValues = array_values($dailySums); sort($dailyValues); $count = count($dailyValues); $median = $count % 2 === 0 ? ($dailyValues[$count / 2 - 1] + $dailyValues[$count / 2]) / 2 : $dailyValues[floor($count / 2)]; $smsCost = self::SMS_COST; $lastBalance = $stats[count($stats) - 1]['balance']; $daysLeft = $median > 0 ? round($lastBalance / ($median * $smsCost), 1) : PHP_INT_MAX; // Отправка уведомления при малом остатке баланса. if ($daysLeft <= 5) { $this->smsSender->send( getenv('ALERT_PHONE'), "⏳ Баланс {$lastBalance} ₽, хватит на {$daysLeft} дней. Пополните счёт." ); } // Отправка уведомления при резком росте отправленных SMS. $today = date('Y-m-d'); $lastDaySum = $dailySums[$today] ?? 0; if ($median > 0 && $lastDaySum > $median * 2) { $criticalDaysLeft = round($lastBalance / ($lastDaySum * $smsCost), 1); $ratio = round($lastDaySum / $median, 1); $this->smsSender->send( getenv('ALERT_PHONE'), "🚨 За сутки отправлено {$lastDaySum} SMS — это в {$ratio} раза больше обычного. Проверьте активность. Баланса хватит ещё на {$criticalDaysLeft} таких дней." ); } } }
Сбор статистики по API — app/Console/Command/AnalyzeBalanceCommand.php
Консольная команда проверяет текущий баланс и уровень трафика. Делегирует работу BalanceAnalyzerService и выводит результаты в консоль. Может использоваться в cron или по расписанию для регулярного мониторинга состояния.
<?php namespace App\Console\Command; use App\Service\BalanceAnalyzerService; class AnalyzeBalanceCommand { private BalanceAnalyzerService $analyzer; public function __construct(BalanceAnalyzerService $analyzer) { $this->analyzer = $analyzer; } public function handle(): void { $this->analyzer->analyze(); } }
Анализ данных и отправка SMS — app/Console/Command/CollectStatsCommand.php
Консольная команда для сбора статистики и сохранения её в базу данных. Получает количество отправленных SMS и текущий баланс, формируя запись с временной меткой.
Собирает данные для последующего анализа динамики расходов и активности сервиса.
В случае ошибки информирует пользователя о причине сбоя через консоль.
<?php namespace App\Console\Command; use App\Repository\SmsStatRepository; use App\Service\ExolveApiService; use App\DTO\SmsStatDTO; use DateTimeImmutable; use DateTimeZone; use Exception; class CollectStatsCommand { private SmsStatRepository $repository; private ExolveApiService $api; public function __construct(SmsStatRepository $repository, ExolveApiService $api) { $this->repository = $repository; $this->api = $api; } /** * @throws Exception */ public function handle(): void { try { $smsCount = $this->api->getSmsCount(); $balanceData = $this->api->getBalance(); $dto = new SmsStatDTO( new DateTimeImmutable('now', new DateTimeZone('UTC')), $smsCount, $balanceData['balance'] ); $this->repository->save($dto); echo "Данные сохранены: {$smsCount} SMS, баланс {$balanceData['balance']} ₽\n"; } catch (Exception $e) { echo "Произошла ошибка: " . $e->getMessage() . "\n"; } } }
Шаг 3. Определение зависимостей и маршрутов
Все компоненты приложения связываются через простой контейнер зависимостей и вызываются по имени через CLI-маршруты — чтобы управлять логикой централизованно и запускать команды без лишней связки между файлами.
Контейнер зависимостей config/container.php
Описывает, как создавать экземпляры сервисов, команд и репозиториев с учётом их зависимостей. Упрощает управление связями между классами и поддерживает единый стиль инициализации компонентов.
<?php use App\Console\Command\AnalyzeBalanceCommand; use App\Console\Command\CollectStatsCommand; use App\Infrastructure\Database; use App\Repository\SmsStatRepository; use App\Service\ExolveApiService; use App\Service\SmsSenderService; use App\Service\BalanceAnalyzerService; return [ 'pdo' => fn() => Database::getConnection(), 'sms_stat_repository' => fn($c) => new SmsStatRepository($c['pdo']()), 'exolve_api_service' => fn() => new ExolveApiService(), 'sms_sender_service' => fn() => new SmsSenderService(), 'balance_analyzer_service' => fn($c) => new BalanceAnalyzerService( $c['sms_stat_repository'](), $c['sms_sender_service']() ), 'collect_stats_command' => fn($c) => new CollectStatsCommand( $c['sms_stat_repository'](), $c['exolve_api_service']() ), 'analyze_balance_command' => fn($c) => new AnalyzeBalanceCommand( $c['balance_analyzer_service']() ), ];
Маршруты CLI-команд routes/console.php
Позволяет запускать команды через artisan.php, указывая их имя в аргументах командной строки. Обеспечивает удобный способ управления командами без жёсткой привязки к конкретным файлам или структурам.
<?php use App\Console\Command\CollectStatsCommand; use App\Console\Command\AnalyzeBalanceCommand; return [ 'collect:stats' => fn($c) => new CollectStatsCommand( $c['sms_stat_repository'](), $c['exolve_api_service']() ), 'analyze:balance' => fn($c) => new AnalyzeBalanceCommand( $c['balance_analyzer_service']() ), ];
Шаг 4. Точка входа для консольных команд — artisan.php
Загружает контейнер и маршруты команд, обрабатывает ввод пользователя из командной строки. Проверяет существование и корректность команды, передавая управление соответствующему обработчику. Позволяет централизованно управлять консольными сценариями без использования сторонних фреймворков.
<?php $container = require __DIR__ . '/bootstrap/app.php'; $routes = require __DIR__ . '/routes/console.php'; $command = $argv[1] ?? null; if (!$command || !isset($routes[$command])) { echo "Неизвестная команда. Доступные команды:\n"; foreach (array_keys($routes) as $name) { echo " - $name\n"; } exit(1); } $handlerFactory = $routes[$command]; if (!is_callable($handlerFactory)) { echo "Ошибка: обработчик команды не является функцией\n"; exit(1); } $handler = $handlerFactory($container); if (!method_exists($handler, 'handle')) { echo "Ошибка: у команды нет метода handle()\n"; exit(1); } $handler->handle();
Шаг 5. Автоматизация с cron
Сбор статистики с cron/collect_stats.sh:
#!/bin/bash php /var/www/html/artisan.php collect:stats
Анализ баланса с cron/analyze_balance.sh:
#!/bin/bash php /var/www/html/artisan.php analyze:balance
Шаг 6. Настройка cron-задач
Для регулярного сбора статистики и анализа баланса настроим cron.
Скрипты уже готовы
cron/collect_stats.sh — для сбора статистики, запускаем каждый час.
cron/analyze_balance.sh — для анализа баланса и проверки аномалий, запускаем раз в день.
Пример crontab-записи для пользователя www-data:
0 * * * * /var/www/html/cron/collect_stats.sh 45 23 * * * /var/www/html/cron/analyze_balance.sh
Шаг 7. Получаем SMS

Заключение
Теперь у нас есть структурированный и современный проект по контролю расходов на SMS — на базе МТС Exolve с хранением статистики в MySQL. В итоге за вечер можно развернуть собственную систему мониторинга расходов, которая будет автоматически собирать данные, анализировать их и присылать уведомления о рисках снижения баланса или неожиданных всплесках активности. Весь код — на GitHub.
Для повышения точности прогнозов можно добавить учёт количества сегментов через параметр segments_count в методе GetList. Также за счёт параметра category можно разделить сообщения на рекламные, авторизационные, транзакционные и сервисные, так как их стоимость различается. Если вам интересно, пишите в комментариях. Мы доработаем решение и опубликуем продолжение статьи.
ссылка на оригинал статьи https://habr.com/ru/articles/930956/
Добавить комментарий