Files
boursecu_server/app/Services/PriceChannelBroadcastService.php
T
2026-07-08 15:51:20 +03:30

765 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Http\Controllers\Controller;
use App\Models\Product;
use Hekmatinasser\Verta\Verta;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* ارسال قیمت لحظه‌ای به کانال‌های پیام‌رسان (بله، تلگرام، روبیکا).
*
* اصول (طبق rule ساعت کاری):
* • هیچ تنظیمی را بازنویسی نمی‌کند — فقط می‌خواند.
* • فقط پیام‌رسان‌هایی که ادمین انتخاب کرده و توکن + مقصد دارند ارسال می‌شود.
* • قیمت تابلو همان buy/sell کش‌شدهٔ /api/price/last است (مثل صفحهٔ اصلی اپ).
*/
class PriceChannelBroadcastService
{
private const COOLDOWN_CACHE_KEY = 'price_channel:last_sent_at';
private const SIGNATURE_CACHE_KEY = 'price_channel:last_signature';
private const SEND_LOCK_KEY = 'price_channel:send_lock';
private const SUPPORTED_MESSENGERS = ['bale', 'telegram', 'rubika'];
/** وزن هر مثقال به گرم — ضریب تبدیل قیمت هر گرم به هر مثقال (مثقال = گرم × این عدد) */
private const DEFAULT_CHANGE_MESGHAL = 4.3318;
/** قالب پیش‌فرض پیام کانال — وقتی price_channel_message_text خالی باشد */
private const DEFAULT_MESSAGE_TEMPLATE = <<<'TXT'
سلام 👋
%عنوان
📅 %تاریخ 🕐 %ساعت
🔴 خرید گرم: %گرم_خرید
🟢 فروش گرم: %گرم_فروش
🔴 خرید مثقال: %مثقال_خرید
🟢 فروش مثقال: %مثقال_فروش
📞 %شماره
%آیدی
%لینک
TXT;
public function __construct(private readonly ConfigService $config)
{
}
/**
* نقطه‌ی ورود — بعد از تغییر قیمت صدا زده می‌شود.
* فقط وقتی قابلیت روشن است، پیام‌رسانی انتخاب شده و فاصله‌ی مجاز گذشته باشد ارسال می‌کند.
*/
public function broadcastIfEligible(): void
{
try {
if (! $this->config->getBool('price_channel_enabled', false)) {
return;
}
$targets = $this->resolveActiveTargets();
if ($targets === []) {
Log::debug('PriceChannel: enabled ولی هیچ مقصد فعالی نیست (توکن/مقصد را چک کن)');
return;
}
// قفل — دو فراخوان هم‌زمان (مثلاً دو force_refresh پشت‌سرهم) را سریالی می‌کند تا
// هر دو قبل از ذخیرهٔ markSent/امضا، cooldown را «گذشته» و امضا را «قدیمی» نبینند و
// پیام تکراری نفرستند. اگر قفل دست کسی است، همین حالا بی‌ارسال برمی‌گردیم.
$lock = Cache::lock(self::SEND_LOCK_KEY, 30);
if (! $lock->get()) {
return;
}
try {
// شرط ۱: حداقل price_channel_min_seconds از آخرین ارسال گذشته باشد (اتمی — جلوی race و چند worker)
if (! $this->acquireSendCooldown()) {
return;
}
$products = $this->eligibleProducts();
$boardProduct = $this->resolveBoardProduct($products);
if ($boardProduct === null) {
Log::debug('PriceChannel: محصول تابلو (آبشده) واجد شرایطی نیست');
return;
}
$trade = $this->resolveLiveTradePrices();
if ($trade === null || $trade['buy'] <= 0) {
Log::debug('PriceChannel: مظنه زنده تابلو در دسترس نیست');
return;
}
// شرط ۲: قیمت تابلو یا قالب پیام نسبت به آخرین ارسال واقعاً تغییر کرده باشد
$signature = $this->broadcastSignature();
if ($signature === Cache::get(self::SIGNATURE_CACHE_KEY)) {
return;
}
$message = $this->buildMessage($products, $boardProduct);
if ($message === '') {
return;
}
$results = [];
foreach ($targets as $messenger => $target) {
$results[$messenger] = $this->dispatchToMessenger($messenger, $target['token'], $target['chat_ids'], $message);
}
// فقط بعد از ارسال واقعی: امضا به‌روزرسانی می‌شود (cooldown در acquireSendCooldown ست شد)
Cache::put(self::SIGNATURE_CACHE_KEY, $signature, now()->addDay());
Log::info('PriceChannel: قیمت به کانال ارسال شد', [
'targets' => array_keys($targets),
'products' => count($products),
'results' => $results,
]);
} finally {
$lock->release();
}
} catch (\Throwable $e) {
Log::warning('PriceChannelBroadcast failed', ['message' => $e->getMessage()]);
}
}
/**
* امضای ارسال — قیمت تابلو + قالب پیام؛ تغییر هر کدام باید ارسال بعدی را مجاز کند.
*/
private function broadcastSignature(): string
{
$prices = $this->computeDisplayPrices();
return md5(implode('|', [
$prices['gram_buy'],
$prices['gram_sell'],
$prices['mesghal_buy'],
$prices['mesghal_sell'],
$this->messageTemplate(),
]));
}
/**
* پس از ذخیرهٔ قالب در ادمین — یک بار فوری ارسال (بدون cooldown و بدون چک امضا).
*/
public function broadcastAfterTemplateChange(): void
{
try {
if (! $this->config->getBool('price_channel_enabled', false)) {
return;
}
$targets = $this->resolveActiveTargets();
if ($targets === []) {
return;
}
$lock = Cache::lock(self::SEND_LOCK_KEY, 30);
if (! $lock->get()) {
return;
}
try {
$products = $this->eligibleProducts();
$boardProduct = $this->resolveBoardProduct($products);
if ($boardProduct === null) {
return;
}
$trade = $this->resolveLiveTradePrices();
if ($trade === null || $trade['buy'] <= 0) {
return;
}
if (! $this->acquireSendCooldown()) {
Log::debug('PriceChannel: قالب عوض شد ولی cooldown هنوز تمام نشده');
return;
}
$message = $this->buildMessage($products, $boardProduct);
if ($message === '') {
return;
}
$results = [];
foreach ($targets as $messenger => $target) {
$results[$messenger] = $this->dispatchToMessenger($messenger, $target['token'], $target['chat_ids'], $message);
}
$signature = $this->broadcastSignature();
Cache::put(self::SIGNATURE_CACHE_KEY, $signature, now()->addDay());
Log::info('PriceChannel: قالب پیام تغییر کرد — ارسال به کانال', [
'targets' => array_keys($targets),
'results' => $results,
]);
} finally {
$lock->release();
}
} catch (\Throwable $e) {
Log::warning('PriceChannel template broadcast failed', ['message' => $e->getMessage()]);
}
}
/**
* ارسال دستی (عیب‌یابی) — cooldown و شرط تغییر قیمت را نادیده می‌گیرد.
* وضعیت روشن/خاموش، انتخاب پیام‌رسان، توکن و مقصد همچنان لازم است.
*
* @return array<string, mixed>
*/
public function forceSend(): array
{
$message = $this->buildMessage();
$targets = $this->resolveActiveTargets();
$results = [];
foreach ($targets as $messenger => $target) {
$results[$messenger] = $message === ''
? ['skipped' => 'پیام خالی است (محصول واجد شرایط نیست)']
: $this->dispatchToMessenger($messenger, $target['token'], $target['chat_ids'], $message);
}
if ($message !== '' && $targets !== []) {
$this->acquireSendCooldown(true);
Cache::put(self::SIGNATURE_CACHE_KEY, $this->broadcastSignature(), now()->addDay());
}
return [
'sent' => $message !== '' && $targets !== [],
'results' => $results,
];
}
/**
* گزارش وضعیت — چرا ارسال می‌شود یا نمی‌شود.
*
* @return array<string, mixed>
*/
public function diagnose(): array
{
$selected = $this->selectedMessengers();
$messengers = [];
foreach (self::SUPPORTED_MESSENGERS as $messenger) {
$token = trim((string) $this->config->getString($this->tokenKey($messenger), ''));
$chatIds = $this->parseList($this->config->getString($this->chatIdsKey($messenger), ''));
$isSelected = in_array($messenger, $selected, true);
$messengers[$messenger] = [
'selected' => $isSelected,
'has_token' => $token !== '',
'destinations' => $chatIds,
'will_send' => $isSelected && $token !== '' && $chatIds !== [],
];
}
$message = $this->buildMessage();
$fromConfig = trim((string) $this->config->getString('price_channel_message_text', ''));
$trade = $this->resolveLiveTradePrices();
$display = $trade !== null ? $this->computeDisplayPrices() : null;
return [
'enabled' => $this->config->getBool('price_channel_enabled', false),
'selected_messengers' => $selected,
'min_seconds' => $this->config->getInt('price_channel_min_seconds', 120),
'cooldown_elapsed' => $this->cooldownElapsed(),
'cooldown_seconds_remaining' => $this->cooldownSecondsRemaining(),
'eligible_products' => count($this->eligibleProducts()),
'messengers' => $messengers,
'active_targets' => array_keys($this->resolveActiveTargets()),
'template_source' => $fromConfig !== '' ? 'db' : 'default',
'live_trade_buy_rial' => $trade['buy'] ?? 0,
'live_trade_sell_rial' => $trade['sell'] ?? 0,
'channel_gram_sell' => $display['gram_sell'] ?? '',
'channel_gram_buy' => $display['gram_buy'] ?? '',
'message_preview' => $message,
];
}
/**
* پیام‌رسان‌های قابل ارسال: انتخاب‌شده + توکن معتبر + حداقل یک مقصد.
*
* @return array<string, array{token: string, chat_ids: array<int, string>}>
*/
private function resolveActiveTargets(): array
{
$selected = $this->selectedMessengers();
if ($selected === []) {
return [];
}
$targets = [];
foreach ($selected as $messenger) {
$token = trim((string) $this->config->getString($this->tokenKey($messenger), ''));
$chatIds = $this->parseList($this->config->getString($this->chatIdsKey($messenger), ''));
if ($token === '' || $chatIds === []) {
continue;
}
$targets[$messenger] = ['token' => $token, 'chat_ids' => $chatIds];
}
return $targets;
}
/** @return array<int, string> */
private function selectedMessengers(): array
{
$raw = $this->parseList($this->config->getString('price_channel_messengers', ''));
return array_values(array_filter(
array_map(static fn ($m) => strtolower(trim($m)), $raw),
static fn ($m) => in_array($m, self::SUPPORTED_MESSENGERS, true)
));
}
private function tokenKey(string $messenger): string
{
return match ($messenger) {
'bale' => 'bale_bot_token',
'telegram' => 'telegram_bot_token',
'rubika' => 'rubika_bot_token',
default => '',
};
}
private function chatIdsKey(string $messenger): string
{
return "price_channel_{$messenger}_to";
}
/**
* ساخت متن پیام قیمت — قالب از price_channel_message_text (یا پیش‌فرض داخلی).
*/
public function buildMessage(?array $products = null, ?Product $boardProduct = null): string
{
$products = $products ?? $this->eligibleProducts();
$boardProduct = $boardProduct ?? $this->resolveBoardProduct($products);
if ($boardProduct === null) {
return '';
}
$trade = $this->resolveLiveTradePrices();
if ($trade === null || $trade['buy'] <= 0) {
return '';
}
return $this->renderMessageTemplate($this->messageTemplate(), $products, $boardProduct);
}
private function messageTemplate(): string
{
$fromConfig = trim((string) $this->config->getString('price_channel_message_text', ''));
return $fromConfig !== '' ? $fromConfig : self::DEFAULT_MESSAGE_TEMPLATE;
}
/**
* جایگزینی متغیرهای قالب.
* قیمت تابلو: %گرم_خرید %گرم_فروش %مثقال_خرید %مثقال_فروش
* زمان: %تاریخ %ساعت — اپ: %اسم_اپ %نام %عنوان
* تماس: %آیدی %شماره %لینک — اختیاری: %محصولات %فوتر
*
* @param array<int, Product> $products
*/
private function renderMessageTemplate(string $template, array $products, Product $boardProduct): string
{
$name = trim((string) $this->config->getString('name_fa', ''));
$title = $name !== '' ? "📊 قیمت لحظه‌ای {$name}" : '📊 قیمت لحظه‌ای';
$prices = $this->computeDisplayPrices();
$message = $this->applyTemplatePlaceholders($template, [
'مثقال_فروش' => $prices['mesghal_sell'],
'مثقال_خرید' => $prices['mesghal_buy'],
'گرم_فروش' => $prices['gram_sell'],
'گرم_خرید' => $prices['gram_buy'],
'اسم_اپ' => $name,
'محصولات' => $this->buildProductLines($products),
'عنوان' => $title,
'تاریخ' => Verta::now()->format('Y/n/j'),
'ساعت' => now()->format('H:i'),
'آیدی' => trim((string) $this->config->getString('price_channel_footer_id', '')),
'شماره' => trim((string) $this->config->getString('price_channel_footer_phone', '')),
'لینک' => trim((string) $this->config->getString('price_channel_footer_link', '')),
'فوتر' => $this->buildFooterBlock(),
'نام' => $name,
]);
return preg_replace("/\n{3,}/", "\n\n", trim($message)) ?? trim($message);
}
/** @param array<string, string> $replacements */
private function applyTemplatePlaceholders(string $template, array $replacements): string
{
$keys = array_keys($replacements);
usort($keys, static fn (string $a, string $b): int => mb_strlen($b) <=> mb_strlen($a));
$message = $template;
foreach ($keys as $key) {
$message = preg_replace(
'/[%٪]\s*' . preg_quote($key, '/') . '/u',
$replacements[$key],
$message
) ?? $message;
}
return $message;
}
/**
* محصول مرجع قیمت تابلو — اول آبشده (گرم)، بعد اولین محصول گرم، بعد اولین واجد شرایط.
*
* @param array<int, Product> $products
*/
private function resolveBoardProduct(array $products): ?Product
{
if ($products === []) {
return null;
}
foreach ($products as $product) {
if (trim((string) ($product->group_type ?? '')) === 'آبشده' && $this->isGramUnit($product)) {
return $product;
}
}
foreach ($products as $product) {
if ($this->isGramUnit($product)) {
return $product;
}
}
return $products[0];
}
/**
* همان buy/sell اپ — کش live_price_api_payload پرشده از /api/price/last.
*
* @return array{buy: int, sell: int}|null
*/
private function resolveLiveTradePrices(): ?array
{
$display = app(ProductDisplayPriceService::class);
$display->warmLivePriceCache();
$cached = Cache::get(ProductDisplayPriceService::LIVE_PRICE_CACHE_KEY);
if (is_array($cached)) {
$buy = (int) ($cached['buy'] ?? 0);
$sell = (int) ($cached['sell'] ?? 0);
if ($buy <= 0) {
$buy = (int) ($cached['price_last'] ?? 0);
}
if ($buy > 0 && $sell > 0) {
return ['buy' => $buy, 'sell' => $sell];
}
}
$base = $display->resolveAppBasePrice();
if ($base <= 0) {
return null;
}
$provider = $this->config->priceProvider();
$priceSource = in_array($provider, ['montzar', 'zaarbox', 'main_product'], true) ? $provider : null;
$flags = $provider === 'montzar' ? $this->config->montzarOpenFlags() : [];
$trade = $this->config->buildTradeDisplayPrices(
(float) $base,
$priceSource,
[
'montzar_open_buy' => $flags['open_buy'] ?? true,
'montzar_open_sell' => $flags['open_sell'] ?? true,
]
);
if ($trade['buy'] <= 0 && $trade['sell'] <= 0) {
return null;
}
return [
'buy' => (int) $trade['buy'],
'sell' => (int) $trade['sell'],
];
}
/**
* قیمت‌های نمایشی تابلو — همان منبع صفحهٔ اصلی اپ + price_channel_diff.
*
* @return array{gram_buy: string, gram_sell: string, mesghal_buy: string, mesghal_sell: string}
*/
private function computeDisplayPrices(): array
{
$channelDiff = (float) $this->config->getInt('price_channel_diff', 0);
$mesghal = $this->changeMesghal();
$trade = $this->resolveLiveTradePrices();
if ($trade === null) {
return [
'gram_buy' => '',
'gram_sell' => '',
'mesghal_buy' => '',
'mesghal_sell' => '',
];
}
// sell در API = خرید مشتری (پایین) — buy در API = فروش مشتری (بالا) — مثل CurrentPrice.vue
$gramBuy = (float) $trade['sell'] - $channelDiff;
$gramSell = (float) $trade['buy'] + $channelDiff;
return [
'gram_buy' => $this->formatPrice($gramBuy),
'gram_sell' => $this->formatPrice($gramSell),
'mesghal_buy' => $mesghal > 0 ? $this->formatPrice($gramBuy * $mesghal) : '',
'mesghal_sell' => $mesghal > 0 ? $this->formatPrice($gramSell * $mesghal) : '',
];
}
/**
* بدنهٔ قیمت محصولات (بدون فوتر) — مبنای تشخیص تغییر قیمت.
*
* @param array<int, Product> $products
*/
private function buildProductLines(array $products): string
{
if ($products === []) {
return '';
}
$channelDiff = (float) $this->config->getInt('price_channel_diff', 0);
$diffBuy = (float) $this->config->displayBuyPriceDiffRial();
$diffSell = (float) $this->config->displaySellPriceDiffRial();
$mesghal = $this->changeMesghal();
$lines = [];
foreach ($products as $product) {
$base = (float) ($product->price ?? 0);
// 🔴 خرید (مشتری می‌فروشد، پایین‌تر) — 🟢 فروش (مشتری می‌خرد، بالاتر)
$low = $base - $diffSell - $channelDiff;
$high = $base + $diffBuy + $channelDiff;
$lines[] = '🛒 ' . trim((string) $product->title);
$lines[] = '🔴 خرید ' . $this->formatPrice($low) . ' 🟢 فروش ' . $this->formatPrice($high);
if ($this->isGramUnit($product) && $mesghal > 0) {
$lines[] = '💰 هر مثقال: ' . $this->formatPrice($low * $mesghal)
. ' / ' . $this->formatPrice($high * $mesghal);
}
$lines[] = '';
}
return trim(implode("\n", $lines));
}
private function buildFooterBlock(): string
{
$footer = $this->footerLines();
if ($footer === []) {
return '';
}
$lines = ['➖➖➖➖➖➖'];
foreach ($footer as $line) {
$lines[] = $line;
}
return implode("\n", $lines);
}
/**
* @return array<int, Product>
*/
private function eligibleProducts(): array
{
$products = Product::query()
->where('active', 1)
->orderBy('id')
->get();
// قیمت زنده را روی مدل‌ها می‌گذارد (بدون UPDATE دیتابیس) تا پیام و تشخیص تغییر
// مستقل از cron/tick و همیشه تازه باشد.
try {
$display = app(ProductDisplayPriceService::class);
$display->warmLivePriceCache();
$display->enrichForApi($products);
} catch (\Throwable $e) {
Log::debug('PriceChannel enrich live price failed', ['message' => $e->getMessage()]);
}
$eligible = [];
foreach ($products as $product) {
if ((int) ($product->price ?? 0) <= 0) {
continue;
}
if (! ($product->open_buy || $product->open_sell)) {
continue;
}
if (! $this->config->isProductWithinTradingHours($product)) {
continue;
}
$eligible[] = $product;
}
return $eligible;
}
private function isGramUnit(Product $product): bool
{
return trim((string) ($product->unit ?? '')) === 'گرم';
}
/** قیمت را به واحد نمایش (تقسیم بر ۱۰٬۰۰۰) با جداکننده‌ی هزارگان می‌برد */
private function formatPrice(float $value): string
{
return number_format(max(0, $value) / 10000, 0);
}
private function changeMesghal(): float
{
$fromConfig = $this->config->get('change_mesghal');
$value = (float) ($fromConfig ?: config('change_mesghal', self::DEFAULT_CHANGE_MESGHAL));
return $value > 0 ? $value : self::DEFAULT_CHANGE_MESGHAL;
}
/**
* خطوط پایانی — فقط مقادیر پرشده نمایش داده می‌شوند (آیدی چنل، شماره، لینک).
*
* @return array<int, string>
*/
private function footerLines(): array
{
$lines = [];
foreach (['price_channel_footer_id', 'price_channel_footer_phone', 'price_channel_footer_link'] as $key) {
$value = trim((string) $this->config->getString($key, ''));
if ($value !== '') {
$lines[] = $value;
}
}
return $lines;
}
/**
* @param array<int, string> $chatIds
* @return array<string, string> chatId => نتیجه (ok / خطا)
*/
private function dispatchToMessenger(string $messenger, string $token, array $chatIds, string $message): array
{
$results = [];
foreach ($chatIds as $chatId) {
try {
$response = match ($messenger) {
'bale' => $this->sendBale($token, $chatId, $message),
'telegram' => $this->sendTelegram($token, $chatId, $message),
'rubika' => $this->sendRubika($token, $chatId, $message),
default => null,
};
$status = $response?->status() ?? 0;
$ok = $response?->successful() ?? false;
$results[$chatId] = $ok ? "ok ({$status})" : "failed ({$status}): " . trim((string) $response?->body());
if (! $ok) {
Log::warning("PriceChannel {$messenger} send not ok", [
'chat_id' => $chatId,
'status' => $status,
'body' => $response?->body(),
]);
}
} catch (\Throwable $e) {
$results[$chatId] = 'exception: ' . $e->getMessage();
Log::warning("PriceChannel {$messenger} send failed", [
'chat_id' => $chatId,
'message' => $e->getMessage(),
]);
}
}
return $results;
}
private function sendBale(string $token, string $chatId, string $message): \Illuminate\Http\Client\Response
{
return Http::withOptions(Controller::httpClientOptions())
->withoutVerifying()
->timeout(6)
->post("https://tapi.bale.ai/bot{$token}/sendMessage", [
'chat_id' => $chatId,
'text' => $message,
]);
}
private function sendTelegram(string $token, string $chatId, string $message): \Illuminate\Http\Client\Response
{
return Http::withOptions(Controller::httpClientOptions())
->timeout(6)
->post("https://api.telegram.org/bot{$token}/sendMessage", [
'chat_id' => $chatId,
'text' => $message,
]);
}
private function sendRubika(string $token, string $chatId, string $message): \Illuminate\Http\Client\Response
{
return Http::withOptions(Controller::httpClientOptions())
->withoutVerifying()
->timeout(6)
->post("https://botapi.rubika.ir/v3/{$token}/sendMessage", [
'chat_id' => $chatId,
'text' => $message,
]);
}
/** حداقل فاصلهٔ مجاز بین دو ارسال — Cache::add اتمی (TTL = min_seconds) */
private function acquireSendCooldown(bool $force = false): bool
{
$minSeconds = max(0, $this->config->getInt('price_channel_min_seconds', 120));
if ($minSeconds === 0 || $force) {
if ($minSeconds > 0) {
Cache::put(self::COOLDOWN_CACHE_KEY, now()->timestamp, $minSeconds);
}
return true;
}
return Cache::add(self::COOLDOWN_CACHE_KEY, now()->timestamp, $minSeconds);
}
private function cooldownElapsed(): bool
{
return $this->cooldownSecondsRemaining() === 0;
}
private function cooldownSecondsRemaining(): int
{
$minSeconds = max(0, $this->config->getInt('price_channel_min_seconds', 120));
if ($minSeconds === 0 || ! Cache::has(self::COOLDOWN_CACHE_KEY)) {
return 0;
}
$lastSent = (int) Cache::get(self::COOLDOWN_CACHE_KEY, 0);
if ($lastSent <= 0) {
return 0;
}
return max(0, $minSeconds - (now()->timestamp - $lastSent));
}
/** @return array<int, string> */
private function parseList(string $raw): array
{
$parts = preg_split('/[,\n]+/', trim($raw)) ?: [];
return array_values(array_filter(array_map('trim', $parts), static fn ($p) => $p !== ''));
}
}