Некоторое время назад я запустил Telegram-бота для мониторинга сайтов и обозначил в нём такой тариф:
Один сайт на мониторинге — бесплатно.
Каждый дополнительный — 2 ₽ в день.
Сколько строк кода получилось из двух строк описания я покажу ниже. По пути расскажу, как обретала свою логику биллинговая система. Возможно, это сэкономит вам немножко мыслетоплива в похожем проекте.
Этап 1: просто возможность оплатить
С самого начала бот затевался как коммерческий проект. Тем более, количество пользователей и добавленных ими сайтов создавало определённую нагрузку на хостинг. Рекорд — 56 сайтов у одного пользователя. Поэтому через пару месяцев после первой моей публикации о боте я решил ввести платный тариф.
Начал с того, что просто добавил удобную возможность оплаты, об этом подробно рассказал в другой статье. Разослал пользователям уведомления о том, что скоро мониторинг двух и более сайтов будет стоить денег, и сразу получил первые несколько оплат. А с ними — чувство эйфории.
Итог: оплата работает, бот доказал свою ценность рублём, можно идти дальше.
Этап 2: ограничения для тех, кто не оплатил
Ещё пару дней после анонсированного срока начала действия платного тарифа в боте ничего не поменялось: новых оплат не было, но и я ещё даже не продумал биллинг. Было ощущение, что выгоднее его вообще не делать ради нескольких платных пользователей.
Но решил, всё же, сделать MVP биллинга из трёх функций:
Предупреждение, а затем и приостановка мониторинга всех сайтов кроме одного у пользователей без оплат (если у них больше 1 сайта).
Автоматическое возобновление мониторинга при поступлении оплаты на любую сумму.
Невозможность добавить второй сайт на мониторинг, если нет ни одной оплаты.
То есть, пользователь мог даже истратить всю сумму пополнения и продолжать пользоваться ботом сколько угодно — списаний с баланса всё ещё нет. Но никто об этом, конечно, не знал.
Итог: получил ещё одну оплату.
Этап 3: списания с баланса по тарифу
Какое-то время размышлял о том, как лучше всего организовать эти самые списания по 2 рубля в день за каждый доп. сайт, и выбрал такой вариант:
В начале суток рассчитываю списания за предыдущие сутки.
Сумма списания не может превышать баланс (чтобы не уводить в минус).
Сохраняю сумму списания в базу с уникальным ключом клиент+дата (страховка от дублирования).
Днём проверяю балансы клиентов (поступления минус списания) и количество подписок у них.
Шлю уведомления тем, у кого баланса хватит меньше чем на 9 дней, если в последние два дня о балансе не уведомлял.
Приостанавливаю доп. сайты тем, у кого баланс 0, при условии что я их уже уведомлял о нулевом балансе минимум сутки назад.
При попытке добавить второй сайт на мониторинг проверяю уже не просто наличие поступлений, а баланс, то есть учитываю списания.
В пункте меню про тарифы добавляю баланс.
Получается, что при нулевом балансе мониторинг продолжает работать бесплатно некоторое время, но в данном случае это сознательный выбор, чтобы не тревожить пользователей ночью и, с другой стороны, не сделать биллинг ещё более сложным. Учитывая, что минимальную сумму пополнения определяю я, эксплуатировать эту особенность мне в убыток не получится.
Техническая реализация
Серверная часть бота запускается по расписанию каждые 5 минут и выполняет проверки для сайтов на мониторинге. Дополнительно, в зависимости от времени суток, выполняются действия по расчёту списаний, по уведомлению пользователей и приостановке мониторинга. Поэтому все функции и запросы к базе рассчитаны на то, что могут запускаться несколько раз в час, а потом долгое время не запускаться — кажется, это повышает надёжность.
Вот так выглядит запрос (MySQL) для расчёта списаний:
SELECT
result.chat_id,
CAST(result.amount_calculated AS SIGNED) AS amount_calculated, -- Сумма списания по тарифу, без учёта баланса
CAST(GREATEST(
0, -- Подстраховка от списания отрицательной суммы из-за отрицательного баланса (по идее, невозможно из условия отбора)
LEAST(result.amount_calculated, result.balance) -- Не списываем в минус
) AS SIGNED) AS amount_fact, -- Сколько фактически списать
CAST(result.balance AS SIGNED) AS balance_before, -- Баланс до списания
result.date_for_charge -- Дата, за которую делаем списание
FROM (
SELECT
chat_id,
FLOOR(GREATEST(
0,
(SUM(TIMESTAMPDIFF(MINUTE, start_date, end_date))/1440 - 1) * 200
)) AS amount_calculated, -- Количество сайто-дней в дату списания, 1 сайто-день бесплатно, каждый дополнительный — 200 копеек в сутки.
payments_sum - COALESCE(charges_sum, 0) AS balance,
first_date_for_charge AS date_for_charge
FROM (
SELECT
s.id,
s.chat_id,
s.created,
s.deleted,
GREATEST(s.created, p.first_date_for_charge) AS start_date,
LEAST(
COALESCE(
s.deleted, -- Если подписка удалена, то до момента удаления,
p.first_date_for_charge + INTERVAL 1 DAY), -- иначе до даты за датой списания.
p.first_date_for_charge + INTERVAL 1 DAY) AS end_date, -- но не позже даты за датой списания.
p.payments_sum,
p.charges_sum,
p.first_date_for_charge
FROM subscriptions s
JOIN (
SELECT
ip.chat_id,
ip.payments_sum,
ch.charges_sum,
ch.last_charge_date,
s.first_subscription_date,
CAST(
GREATEST(
s.first_subscription_date,
COALESCE(
ch.last_charge_date,
:tariff_start_date
) + INTERVAL 1 DAY
) AS date
) AS first_date_for_charge -- Самая старая дата, за которую нужно сделать списание у пользователя
FROM (
SELECT chat_id, SUM(amount) AS payments_sum
FROM income_payments
GROUP BY chat_id
) ip
LEFT JOIN (
SELECT chat_id, SUM(amount_fact) AS charges_sum, MAX(date_for_charge) AS last_charge_date
FROM charges
GROUP BY chat_id
) ch ON ch.chat_id = ip.chat_id
JOIN (
SELECT min(created) AS first_subscription_date, chat_id
FROM subscriptions
GROUP BY chat_id
) s ON s.chat_id = ip.chat_id
WHERE
ip.payments_sum - COALESCE(ch.charges_sum, 0) > 0 -- Если есть что списать
AND (
ch.last_charge_date IS NULL -- Если не было списаний
OR ch.last_charge_date < NOW() - INTERVAL 2 DAY -- Или последнее списание было больше 2 дней назад
)
) p ON p.chat_id = s.chat_id
WHERE -- Ищем подписки, действовашие в дату списания
(s.deleted IS NULL OR s.deleted > p.first_date_for_charge)
AND
(s.created < p.first_date_for_charge + INTERVAL 1 DAY)
) total
GROUP BY
chat_id,
payments_sum,
charges_sum,
first_date_for_charge
) result
;
Пояснения:
chat_id
в данном случае можно считать идентификатором клиента.subscriptions
— таблица с подписками, 1 подписка — 1 сайт на мониторинге.charges
— таблица со списаниями.income_payments
— таблица с пополнениями баланса.:tariff_start_date
— единственный передаваемый параметр, начало действия платного тарифа.Все суммы считаются в копейках как целые числа.
Приведение типов (
CAST
) добавлено из-за того, что без него php после pdo эти числа получает в виде строк.Для каждого подписчика определяется самая ранняя дата, за которую нужно рассчитать списание — это необязательно вчерашний день. Сделано так для расчёта списаний за период с объявления платного тарифа до момента его имплементации в коде. Запрос выполняется несколько раз за сутки, постепенно нагоняя упущенные дни. В штатном режиме после первого же выполнения списаний в течение этих суток запрос уже не будет ничего возвращать.
Для каждого пользователя рассчитываем, сколько сайто-дней приходилось на расчётную дату, 1 сайт вычитаем, а остаток умножаем на суточный тариф. Получается, если половину суток было 2 сайта, а вторую половину — ни одного, то списания не будет.
Клиенты без оплат в выборку не попадают — с них списать всё равно нечего.
Если по итогу получилось нулевое списание, оно всё равно сохраняется, чтобы потом этот клиент с этой датой уже не попадал в выборку.
Запрос выглядит, возможно, громоздко, но быстро работает и выдаёт в готовом виде все необходимые данные для списаний. На стороне php остаётся только взять нужные поля и поместить в таблицу списаний, попутно залогировав.
Итог
Теперь биллинг можно считать законченным — есть и пополнения, и мотивация для пополнений, и списания согласно тарифу. Некоторое чувство велосипедостроения присутствует, но чувство творческого удовлетворения его перевешивает. Возможно, подобная задача могла бы быть решена как-то более эффективно — пишите, будет интересно обсудить.
Да и самого бота, конечно, тоже пробуйте, я не только над биллингом в нём потрудился!
Комментарии (9)
j-b
25.01.2025 08:24Вот бы еще послушать как вы замеряли время ответа, и не попадает ли туда время работы самого скрипта, а так-же как часто вы эти запросы делаете? Ведь время реакции, скажем на "Упавший" магазин может стоить очень дорого...
Pontific Автор
25.01.2025 08:24Замеряю чисто время отработки curl, вот так:
$start_time = microtime(true); $response = curl_exec($ch); $end_time = microtime(true); $result['response_time_ms'] = intval(round(($end_time - $start_time) * 1000));
Далее, когда сайт уже на мониторинге, беру два последних результата и сравниваю их с последним сообщённым пользователю временем ответа.
Если минимальное время из двух последних в два раза превышает последнее сообщённое время — сообщаю это новое время.
Если максимальное из двух последних в два раза меньше последнего сообщённого — тоже сообщаю.
В обоих случаях разница должна превышать минимальный порог в секунду.
Запросы делаю сейчас раз в 5 минут. Если 2 запроса подряд без ответа — шлю уведомление. К сожалению, многие сайты с завидной регулярностью "мерцают", пришлось добавить такой фильтр. Но некоторые и два раза подряд могут быть недоступны, а на третий раз отвечают — не знаю, с чем связано.
j-b
25.01.2025 08:24Т.е. аналитики времени запросов нет как я понимаю,только последние значения...
Много желающий платить, окупаются затраты на хостинг?
Pontific Автор
25.01.2025 08:24Есть ещё регулярный отчёт за сутки/неделю, в нём можно посмотреть статистику времени ответов, если Вы об этом:
Pontific Автор
25.01.2025 08:24Много желающий платить, окупаются затраты на хостинг?
Не больше десятка пока что. Хостинг примерно окупается, да.
CodeByZen
25.01.2025 08:24У меня глаза начали кровоточить от sql запроса. :) По-моему это очень сложно. Я бы разбил на хранимые функции как минимум для удобства чтения.
А по проверке самих сайтов: если у пользователя отвалился бекенд то сервер вернёт 200, а при запросе к api пользователь ничего не увидит. Т.е. сайт лег. Но вы этого не увидите. И будете говорить, что с сайтом все ок.
Pontific Автор
25.01.2025 08:24Я бы разбил на хранимые функции как минимум для удобства чтения.
Из преимуществ текущего запроса — всё в одном месте, можно читать не переключаясь.
Но идея с хранимыми функциями мне тоже нравится. Если будет настроение и приведёте пример, какие можно было бы выделить функции — будет интересно.
если у пользователя отвалился бекенд то сервер вернёт 200, а при запросе к api пользователь ничего не увидит
Тут зависит от сайта. Если из-за неработающего API поменяется заголовок страницы, то бот сообщит. Можно, например, создать отдельную проверочную страницу для этих целей.
Можно отдельно добавить сам API в мониторинг (правда, никакой авторизации я не предусматривал).
ednersky
а можете привести получившийся план этого запроса?
Pontific Автор