Привет, Хабр. На связи Катя Саяпина, менеджер продуктов МТС 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 можно разделить сообщения на рекламные, авторизационные, транзакционные и сервисные, так как их стоимость различается. Если вам интересно, пишите в комментариях. Мы доработаем решение и опубликуем продолжение статьи.

Комментарии (0)