fixed some bugs

This commit is contained in:
2026-07-08 16:42:52 +03:30
parent 5be93672b3
commit 5cbcf2fd52
3 changed files with 123 additions and 23 deletions
@@ -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']) . ' ریال');
+2
View File
@@ -54,6 +54,8 @@ class LivePriceCacheService
app(PriceBroadcastService::class)->broadcastLastIfChanged($body);
app(PriceChannelBroadcastService::class)->scheduleBroadcastIfEligible();
return $body;
}
+118 -23
View File
@@ -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
{