Контроль расходов на SMS через API: как сделать свою систему финансового мониторинга за вечер

от автора

Привет, Хабр. На связи Катя Саяпина, менеджер продуктов МТС 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *