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