AI Динамические телефонные номера для отслеживания рекламных креативов: как построить систему на Yii2 и МТС Exolve

AI

Редактор
Регистрация
23 Август 2023
Сообщения
3 041
Лучшие ответы
0
Реакции
0
Баллы
51
Offline
#1


Привет, Хабр! Одна из ключевых задач performance-маркетинга — понять, какая реклама реально приводит клиентов. Для кликов есть Яндекс.Метрика, но когда одно из целевых действий — звонок, анализировать источники сложнее, а значит и понять какой креатив работает лучше.

Использование динамических виртуальных номеров позволяет сопоставить звонки с конкретным рекламным источником. Для каждого визита система подставляет уникальный номер телефона и фиксирует обращение. Когда клиент звонит, мы связываем его звонок с конкретной рекламой.

В этой статье мы разберём:


  • Общую схему работы


  • Как реализовать её на PHP Yii2


  • Покажем код с архитектурой


  • Обсудим проблемы и пути развития
Общая схема работы


  1. Пользователь кликает по рекламе и попадает на посадочную страницу с UTM-метками


  2. На странице динамически подставляется номер телефона из пула ваших свободных номеров в МТС Exolve


  3. Визит сохраняется в базе вместе с номером, UTM-метками и параметрами браузера


  4. Номер бронируется за пользователем на определённое время, например, на 1 час


  5. Если в течение этого времени на номер позвонят, МТС Exolve отправит вебхук


  6. Мы фиксируем факт звонка и связываем его с визитом


  7. Если звонка не было — номер освобождается
Архитектура решения


Для удобства поддержки и масштабирования мы разделяем систему на четыре слоя:


  • Контроллер — принимает запросы от фронтенда, например, сохранение визита


  • Сервис — содержит бизнес-логику, такую как резервирование номера и фиксация звонка


  • Репозиторий — работает только с базой данных, не содержит бизнес-логики


  • Клиент — отдельный класс для взаимодействия с внешним API МТС Exolve

Такой подход позволяет оставлять контроллеры «тонкими», удобно тестировать бизнес-логику, а интеграции с внешними сервисами менять без переделки всего приложения.

Таблицы базы данных


Для демонстрации мы используем три основные сущности:


  • phone — пул виртуальных номеров;


  • visit — визиты пользователей, включая номер и UTM-метки;


  • call — звонки, связанные с визитами.
Создание таблиц и связей


Файл: m250913_135055_create_dynamic_numbers_tables.php

Создаёт таблицы phone, visit и call, добавляет индексы и связи между таблицами.

<?php
use yii\db\Migration;
class m250913_135055_create_dynamic_numbers_tables extends Migration
{
public function safeUp()
{
$this->createTable('{{%phone}}', [
'id' => $this->primaryKey(),
'number' => $this->string(20)->notNull()->unique(),
'status' => $this->string(20)->notNull(),
'created_at' => $this->dateTime()->defaultExpression('NOW()'),
'updated_at' => $this->dateTime()->append('ON UPDATE NOW()'),
]);
$this->createTable('{{%visit}}', [
'id' => $this->primaryKey(),
'phone_number' => $this->string(20)->notNull(),
'utm_source' => $this->string(50),
'utm_campaign' => $this->string(50),
'utm_medium' => $this->string(50),
'ip' => $this->string(45),
'user_agent' => $this->text(),
'created_at' => $this->dateTime()->defaultExpression('NOW()'),
'updated_at' => $this->dateTime()->append('ON UPDATE NOW()'),
]);
$this->createTable('{{%call}}', [
'id' => $this->primaryKey(),
'call_id' => $this->string(50)->notNull()->unique(),
'phone_number' => $this->string(20)->notNull(),
'visit_id' => $this->integer(),
'created_at' => $this->dateTime()->defaultExpression('NOW()'),
'updated_at' => $this->dateTime()->append('ON UPDATE NOW()'),
]);
$this->createIndex('idx-visit-phone_number', '{{%visit}}', 'phone_number');
$this->createIndex('idx-call-phone_number', '{{%call}}', 'phone_number');
$this->addForeignKey('fk-call-visit_id', '{{%call}}', 'visit_id', '{{%visit}}', 'id', 'SET NULL', 'CASCADE');
}
public function safeDown()
{
$this->dropForeignKey('fk-call-visit_id', '{{%call}}');
$this->dropTable('{{%call}}');
$this->dropTable('{{%visit}}');
$this->dropTable('{{%phone}}');
}
}

Репозиторий и сервисы


Здесь мы разберём ключевые части системы, которые позволяют вам управлять данными и логикой сервиса, чтобы всё работало стабильно и прозрачно.

Управление пулом номеров


Файл: app/Repository/PhoneRepository.php Отвечает за управление пулом номеров. Здесь сосредоточены операции поиска первого свободного номера, его бронирования и освобождения. Репозиторий гарантирует, что система не будет работать с «грязными» данными, что гарантирует корректность статусов телефонов.

<?php
namespace app\Repository;
use app\models\Phone;
use RuntimeException;
class PhoneRepository
{
public function add(Phone $model): void
{
if (!$model->getIsNewRecord()) {
throw new RuntimeException('Adding existing model.');
}
if (!$model->insert(false)) {
throw new RuntimeException('Saving error.');
}
}
public function findFree(): ?Phone
{
return Phone::find()
->where(['status' => Phone::STATUS_FREE])
->orderBy(['updated_at' => SORT_ASC]) // самые старые свободные номера первыми
->one();
}
public function markReserved(int $id): void
{
Phone::updateAll(['status' => Phone::STATUS_RESERVED], ['id' => $id]);
}
public function markFree(string $number): void
{
Phone::updateAll(['status' => Phone::STATUS_FREE], ['number' => $number]);
}
}

Бизнес-логика резервирования номеров


Файл: app/Service/PhoneService.php Добавляет к операциям над номерами бизнес-смысл. Если PhoneRepository просто ищет телефон, то сервис сразу же переводит его в статус «занят». Это исключает ситуации, когда один и тот же номер случайно закрепится за двумя пользователями.

<?php
namespace app\Service;
use app\Repository\PhoneRepository;
use Yii;
use yii\db\Exception;
class PhoneService
{
public function __construct(private PhoneRepository $phoneRepository)
{
}
/**
* @throws Exception
*/
public function reserve(): ?string
{
$transaction = Yii::$app->db->beginTransaction();
try {
$phone = $this->phoneRepository->findFree();
if (!$phone) {
$transaction->rollBack();
return null;
}
$this->phoneRepository->markReserved($phone->id);
$transaction->commit();


return $phone->number;
} catch (Exception $e) {
$transaction->rollBack();
throw $e;
}
}
public function release(string $number): void
{
$this->phoneRepository->markFree($number);
}
}

Хранение визитов и поиск по номеру


Файл: app/Repository/VisitRepository.php Репозиторий для работы с визитами. Он сохраняет визиты пользователей вместе с их атрибутами: IP-адрес, UTM-метки, браузер, телефон. Кроме того, именно он умеет находить визит по номеру телефона, что становится ключевым при обработке звонка.

<?php
namespace app\Repository;
use app\models\Visit;
use RuntimeException;
class VisitRepository
{
public function add(Visit $model): void
{
if (!$model->getIsNewRecord()) {
throw new RuntimeException('Adding existing model.');
}
if (!$model->insert(false)) {
throw new RuntimeException('Saving error.');
}
}
public function findByPhoneWithinHour(string $number): ?Visit
{
$threshold = date('Y-m-d H:i:s', time() - 3600);
return Visit::find()
->where(['phone_number' => $number])
->andWhere(['>=', 'created_at', $threshold])
->one();
}
}
Проверка данных визита


Файл: app/Forms/VisitForm.php Обеспечивает проверку входных данных при создании визита: IP, UTM-меток и браузера. Таким образом бизнес-логика получает уже гарантированно корректные данные, а вся валидация сосредоточена в одном месте.

<?php
namespace app\Forms;
use app\models\Visit;
use yii\base\Model;
class VisitForm extends Model
{
public $utm_source;
public $utm_medium;
public $utm_campaign;
public $ip;
public $user_agent;
public function __construct(Visit $visit = null, $config = [])
{
if ($visit) {
$this->utm_source = $visit->utm_source;
$this->utm_medium = $visit->utm_medium;
$this->utm_campaign = $visit->utm_campaign;
$this->ip = $visit->ip;
$this->user_agent = $visit->user_agent;
}
parent::__construct($config);
}
public function rules()
{
return [
[['ip'], 'ip'],
[['utm_source', 'utm_medium', 'utm_campaign', 'user_agent'], 'string'],
];
}
}

Создание визита и бронирование номера


Файл: app/Service/VisitService.php Контролирует процесс создания визита. Он принимает входные данные от контроллера (IP, UTM, браузер), запрашивает номер у PhoneService, а затем сохраняет визит через VisitRepository.

<?php
namespace app\Service;
use app\forms\VisitForm;
use app\models\Visit;
use app\Repository\VisitRepository;
class VisitService
{
public function __construct(
private VisitRepository $visitRepository,
private PhoneService $phoneService
) {}
public function create(VisitForm $form): Visit
{
$phone = $this->phoneService->reserve();
if (!$phone) throw new \DomainException('Нет свободных номеров');
$entity = Visit::create(
$phone,
$form->utm_source,
$form->utm_medium,
$form->utm_campaign,
$form->ip,
$form->user_agent,
);
$this->visitRepository->add($entity);
return $entity;
}
}

Обработка запроса на создание визита


Файл: app/controllers/VisitController.php Здесь происходит приём AJAX-запросов с сайта. Контроллер проверяет входящие данные через форму VisitForm, передаёт их в VisitService и возвращает результат в формате JSON. Это может быть как выданный номер телефона, так и ошибка в случае некорректных данных. Контроллер выполняет только функцию «получил → проверил → передал дальше → отдал ответ».

<?php
namespace app\controllers;
use Yii;
use yii\web\Controller;
use app\Service\VisitService;
use app\Forms\VisitForm;
use yii\web\Response;
class VisitController extends Controller
{
public $enableCsrfValidation = false;
public function __construct(
$id,
$module,
private VisitService $service,
$config = []
) {
parent::__construct($id, $module, $config);
}
public function actionCreate()
{
Yii::$app->response->format = Response::FORMAT_JSON;
$request = json_decode(Yii::$app->request->getRawBody(), true);
$form = new VisitForm();
if ($form->load($request) && $form->validate()) {
try {
$form->ip = Yii::$app->request->userIP;
$visit = $this->service->create($form);
return $this->asJson(['phone' => $visit->phone_number]);
} catch (\DomainException $e) {
Yii::$app->errorHandler->logException($e);
return $this->asJson(['error' => $e->getMessage()]);
}
}
return $this->asJson(['error' => 'Invalid data']);
}
}
Хранение данных о звонках


Файл: app/Repository/CallRepository.php Хранит факты звонков. Его задача — сохранять новые звонки и следить за уникальностью call_id. Благодаря этому система защищена от дублирующихся данных, например, при повторных уведомлениях от телефонии.

<?php
namespace app\Repository;
use app\models\Call;
use RuntimeException;
class CallRepository
{
public function add(Call $model): void
{
if (!$model->getIsNewRecord()) {
throw new RuntimeException('Adding existing model.');
}
if (!$model->insert(false)) {
throw new RuntimeException('Saving error.');
}
}
public function findByCallId(string $callId): bool
{
return Call::find()
->where(['call_id' => $callId])
->exists();
}
}

Привязка звонка к визиту


Файл: app/Service/CallService.php Обрабатывает звонки. Он получает данные от телефонии через ExolveClient, определяет номер, по которому был звонок, и находит соответствующий визит через VisitRepository. После этого фиксирует звонок в базе с помощью CallRepository. Таким образом звонок становится частью атрибуции и связывается с конкретным визитом.

<?php
namespace app\Service;
use app\Client\ExolveClient;
use app\models\Call;
use app\Repository\CallRepository;
use app\Repository\VisitRepository;
use Yii;
/**
* Сохраняет факт звонка и связывает его с визитом.
*
* Метод handleCall вызывается при завершении звонка (например, через webhook от системы телефонии).
*/
class CallService
{
public function __construct(
private ExolveClient $exolveClient,
private CallRepository $callRepository,
private VisitRepository $visitRepository,
private PhoneService $phoneService,
) {}
public function handle(string $callId): bool
{
if ($this->callRepository->findByCallId($callId)) {
return false;
}
$data = $this->exolveClient->getInfo($callId);
$number = $data['to'] ?? null;


if (!$number || !is_string($number)) {
return false;
}
$visit = $this->visitRepository->findByPhoneWithinHour($number);


$entity = Call::create(
$callId,
$number,
$visit?->id,
);


$db = Yii::$app->db;
return $db->transaction(function () use ($entity, $number) {
$this->callRepository->add($entity);
$this->phoneService->release($number);
return true;
});
}
}

Интеграция с МТС Exolve


Файл: app/Client/ExolveClient.php Инкапсулирует работу с API телефонии. Основной метод — GetInfo, который используется для получения информации о звонке. Этот класс отправляет запросы к API, получает данные о звонках, проверяет корректность ответа и возвращает результат в удобном формате для сервисов.

<?php
namespace app\Client;
use Yii;
use yii\httpclient\Client;
use Exception;
class ExolveClient
{
private const ENDPOINT = "https://api.exolve.ru";
private Client $httpClient;
private string $apiKey;
public function __construct()
{
$this->httpClient = new Client(['baseUrl' => self::ENDPOINT]);
$this->apiKey = Yii::$app->params['exolve']['apiKey'] ?? '';
}
/**
* Получение информации о звонке по call_id
*
* @param string $callId
* @return ?array
* @throws Exception
*/
public function getInfo(string $callId): ?array
{
try {
$response = $this->httpClient->post(
'/statistics/call-history/v2/GetInfo',
['call_id' => [$callId]]
)
->addHeaders(['Authorization' => "Bearer {$this->apiKey}"])
->setFormat(Client::FORMAT_JSON)
->send();


if (!$response->isOk) {
\Yii::error("Ошибка Exolve API: {$response->content}", __METHOD__);
return null;
}
return $response->data ?? null;
} catch (\Throwable $e) {
\Yii::error("Сбой при обращении к Exolve: {$e->getMessage()}", __METHOD__);
return null;
}
}
}

Клиентская часть на JavaScript


Для корректной атрибуции звонка к конкретному визиту и рекламной кампании этот код собирает данные визита пользователя на сайте — такие как UTM-метки и браузер — и автоматически подставляет нужный номер телефона на страницу.

function getUTM() {
const params = new URLSearchParams(window.location.search);
return {
utm_source: params.get('utm_source'),
utm_medium: params.get('utm_medium'),
utm_campaign: params.get('utm_campaign')
};
}
fetch('/visit/create', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
VisitForm: {
user_agent: navigator.userAgent,
...getUTM()
}
})
}).then(r => r.json()).then(data => {
if (data.phone) {
document.getElementById('phone').innerText = data.phone;
}
if (data.error) {
console.error('Ошибка при создании визита:', data.error);
}
});

Заключение


Система динамических номеров позволяет точно сопоставлять звонки с рекламными источниками. Номера подставляются автоматически, визиты фиксируются без ошибок, а бронирование номера на некоторое время за посетителем гарантирует корректную связь звонков с визитами.

Важно учесть нюанс — после окончания периода бронирования номер освобождается и может быть назначен другому пользователю. Чтобы уменьшить риск пересечений, имеет смысл расширять пул номеров или увеличивать время их удержания за посетителем.

В этом примере показаны только ключевые компоненты и общая архитектура системы. В кодовой базе нет моделей Phone, Visit и Call, а также логики освобождения номеров при отсутствии звонка. Для полноценного развёртывания потребуется установка Yii2, настройка зависимостей и разработка этих модулей. Если вам интересно собрать полную систему, напишите в комментариях, и в следующем материале разберём реализацию более детально.

Идеи для развития


  • Добавить расширенные параметры браузера и географию.


  • Строить отчёты по кампаниям в Telegram или Grafana.


  • При атрибуции учитывать повторные визиты.


  • Интегрировать с CRM.


  • Получать пул номеров автоматически из МТС Exolve.


  • Автоматически докупать виртуальные номера при большом трафике.


  • При звонке возвращать конверсию в рекламную систему ВК или Яндекс Директ.
 
Сверху Снизу