From 5cbcf2fd5224ff6ab244ece5297ffd97659549d3 Mon Sep 17 00:00:00 2001 From: adonis Date: Wed, 8 Jul 2026 16:42:52 +0330 Subject: [PATCH] fixed some bugs --- app/Console/Commands/PriceChannelTest.php | 3 + app/Services/LivePriceCacheService.php | 2 + app/Services/PriceChannelBroadcastService.php | 141 +++++++++++++++--- 3 files changed, 123 insertions(+), 23 deletions(-) diff --git a/app/Console/Commands/PriceChannelTest.php b/app/Console/Commands/PriceChannelTest.php index dfbda74..c50d08c 100644 --- a/app/Console/Commands/PriceChannelTest.php +++ b/app/Console/Commands/PriceChannelTest.php @@ -21,6 +21,9 @@ class PriceChannelTest extends Command $this->line('پیام‌رسان‌های انتخاب‌شده: ' . (implode(', ', $info['selected_messengers']) ?: '— (خالی)')); $this->line('حداقل فاصله ارسال (ثانیه): ' . $info['min_seconds']); $this->line('فاصله cooldown گذشته؟ ' . ($info['cooldown_elapsed'] ? 'بله' : 'خیر (هنوز ' . ($info['cooldown_seconds_remaining'] ?? 0) . ' ثانیه مانده)')); + if (array_key_exists('signature_changed', $info)) { + $this->line('قیمت نسبت به آخرین ارسال کانال عوض شده؟ ' . ($info['signature_changed'] ? 'بله' : 'خیر (همین دلیل عدم ارسال)')); + } $this->line('منبع قالب: ' . ($info['template_source'] === 'db' ? 'تنظیمات ادمین' : 'پیش‌فرض داخلی')); if (! empty($info['live_trade_buy_rial'])) { $this->line('قیمت فروش اپ (buy API): ' . number_format((int) $info['live_trade_buy_rial']) . ' ریال'); diff --git a/app/Services/LivePriceCacheService.php b/app/Services/LivePriceCacheService.php index 7d9bd99..83c66ce 100644 --- a/app/Services/LivePriceCacheService.php +++ b/app/Services/LivePriceCacheService.php @@ -54,6 +54,8 @@ class LivePriceCacheService app(PriceBroadcastService::class)->broadcastLastIfChanged($body); + app(PriceChannelBroadcastService::class)->scheduleBroadcastIfEligible(); + return $body; } diff --git a/app/Services/PriceChannelBroadcastService.php b/app/Services/PriceChannelBroadcastService.php index 381f5cc..904001b 100644 --- a/app/Services/PriceChannelBroadcastService.php +++ b/app/Services/PriceChannelBroadcastService.php @@ -25,6 +25,8 @@ class PriceChannelBroadcastService private const SEND_LOCK_KEY = 'price_channel:send_lock'; + private const DEFERRED_BROADCAST_KEY = 'price_channel:deferred_broadcast'; + private const SUPPORTED_MESSENGERS = ['bale', 'telegram', 'rubika']; /** وزن هر مثقال به گرم — ضریب تبدیل قیمت هر گرم به هر مثقال (مثقال = گرم × این عدد) */ @@ -50,6 +52,28 @@ TXT; { } + /** + * صف ارسال کانال — همان لحظهٔ تغییر قیمت اپ (مثل SSE)، نه فقط cron. + */ + public function scheduleBroadcastIfEligible(): void + { + if (! $this->config->getBool('price_channel_enabled', false)) { + return; + } + + $dispatch = static function (): void { + app(self::class)->broadcastIfEligible(); + }; + + if (app()->runningInConsole()) { + $dispatch(); + + return; + } + + dispatch($dispatch)->afterResponse(); + } + /** * نقطه‌ی ورود — بعد از تغییر قیمت صدا زده می‌شود. * فقط وقتی قابلیت روشن است، پیام‌رسانی انتخاب شده و فاصله‌ی مجاز گذشته باشد ارسال می‌کند. @@ -64,58 +88,83 @@ TXT; $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(); + // اول امضا — بدون مصرف cooldown (باگ قبلی: cooldown قبل از امضا باعث می‌شد + // هر ۱۲۰ ثانیه یک «اسلات» بسوزد حتی وقتی قیمت عوض نشده یا ارسال ناموفق بود) + $signature = $this->broadcastSignature($trade); if ($signature === Cache::get(self::SIGNATURE_CACHE_KEY)) { return; } + if (! $this->cooldownElapsed()) { + $this->scheduleDeferredBroadcast($this->cooldownSecondsRemaining() + 1); + Log::debug('PriceChannel: قیمت عوض شده ولی cooldown هنوز تمام نشده — ارسال به‌تأخیر افتاد', [ + 'remaining' => $this->cooldownSecondsRemaining(), + ]); + + return; + } + $message = $this->buildMessage($products, $boardProduct); if ($message === '') { return; } + if (! $this->acquireSendCooldown()) { + return; + } + $results = []; + $anyOk = false; foreach ($targets as $messenger => $target) { $results[$messenger] = $this->dispatchToMessenger($messenger, $target['token'], $target['chat_ids'], $message); + foreach ((array) $results[$messenger] as $status) { + if (is_string($status) && str_starts_with($status, 'ok')) { + $anyOk = true; + } + } + } + + if (! $anyOk) { + Cache::forget(self::COOLDOWN_CACHE_KEY); + Log::warning('PriceChannel: هیچ ارسالی موفق نبود — cooldown آزاد شد تا تلاش مجدد', [ + 'results' => $results, + ]); + + return; } - // فقط بعد از ارسال واقعی: امضا به‌روزرسانی می‌شود (cooldown در acquireSendCooldown ست شد) Cache::put(self::SIGNATURE_CACHE_KEY, $signature, now()->addDay()); Log::info('PriceChannel: قیمت به کانال ارسال شد', [ 'targets' => array_keys($targets), 'products' => count($products), + 'buy_rial' => $trade['buy'], + 'sell_rial' => $trade['sell'], 'results' => $results, ]); } finally { @@ -127,17 +176,22 @@ TXT; } /** - * امضای ارسال — قیمت تابلو + قالب پیام؛ تغییر هر کدام باید ارسال بعدی را مجاز کند. + * امضای ارسال — buy/sell خام API (ریال) + قالب؛ دقیق‌تر از رشتهٔ فرمت‌شدهٔ نمایشی. + * + * @param array{buy: int, sell: int}|null $trade */ - private function broadcastSignature(): string + private function broadcastSignature(?array $trade = null): string { - $prices = $this->computeDisplayPrices(); + $trade ??= $this->resolveLiveTradePrices(); + if ($trade === null) { + return ''; + } + + $channelDiff = (int) $this->config->getInt('price_channel_diff', 0); return md5(implode('|', [ - $prices['gram_buy'], - $prices['gram_sell'], - $prices['mesghal_buy'], - $prices['mesghal_sell'], + (int) $trade['buy'] + $channelDiff, + (int) $trade['sell'] - $channelDiff, $this->messageTemplate(), ])); } @@ -174,8 +228,9 @@ TXT; return; } - if (! $this->acquireSendCooldown()) { + if (! $this->cooldownElapsed()) { Log::debug('PriceChannel: قالب عوض شد ولی cooldown هنوز تمام نشده'); + return; } @@ -184,12 +239,28 @@ TXT; return; } - $results = []; - foreach ($targets as $messenger => $target) { - $results[$messenger] = $this->dispatchToMessenger($messenger, $target['token'], $target['chat_ids'], $message); + if (! $this->acquireSendCooldown()) { + return; } - $signature = $this->broadcastSignature(); + $results = []; + $anyOk = false; + foreach ($targets as $messenger => $target) { + $results[$messenger] = $this->dispatchToMessenger($messenger, $target['token'], $target['chat_ids'], $message); + foreach ((array) $results[$messenger] as $status) { + if (is_string($status) && str_starts_with($status, 'ok')) { + $anyOk = true; + } + } + } + + if (! $anyOk) { + Cache::forget(self::COOLDOWN_CACHE_KEY); + + return; + } + + $signature = $this->broadcastSignature($trade); Cache::put(self::SIGNATURE_CACHE_KEY, $signature, now()->addDay()); Log::info('PriceChannel: قالب پیام تغییر کرد — ارسال به کانال', [ @@ -267,6 +338,8 @@ TXT; 'min_seconds' => $this->config->getInt('price_channel_min_seconds', 120), 'cooldown_elapsed' => $this->cooldownElapsed(), 'cooldown_seconds_remaining' => $this->cooldownSecondsRemaining(), + 'signature_changed' => $trade !== null + && $this->broadcastSignature($trade) !== Cache::get(self::SIGNATURE_CACHE_KEY), 'eligible_products' => count($this->eligibleProducts()), 'messengers' => $messengers, 'active_targets' => array_keys($this->resolveActiveTargets()), @@ -719,6 +792,28 @@ TXT; ]); } + /** وقتی قیمت عوض شده ولی cooldown مانده — یک بار بعد از اتمام cooldown دوباره تلاش کن */ + private function scheduleDeferredBroadcast(int $delaySeconds): void + { + $delaySeconds = max(1, min($delaySeconds, 600)); + if (! Cache::add(self::DEFERRED_BROADCAST_KEY, now()->timestamp, $delaySeconds + 5)) { + return; + } + + $dispatch = static function (): void { + Cache::forget(self::DEFERRED_BROADCAST_KEY); + app(self::class)->broadcastIfEligible(); + }; + + if (app()->runningInConsole()) { + // در tick/cron: تلاش مجدد در همان فرایند بعد از cooldown کافی نیست؛ + // tick بعدی (۱۰ ثانیه) دوباره broadcastIfEligible را صدا می‌زند. + return; + } + + dispatch($dispatch)->delay(now()->addSeconds($delaySeconds)); + } + /** حداقل فاصلهٔ مجاز بین دو ارسال — Cache::add اتمی (TTL = min_seconds) */ private function acquireSendCooldown(bool $force = false): bool {