diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index da4ef86..f538c49 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -76,12 +76,14 @@ class Controller extends BaseController } /** - * گزینه‌های Guzzle/cURL — فقط وقتی افزونه curl و ثابت‌ها موجود باشند. + * Direct connect (no local VPN/proxy) + IPv4 + HTTP/1.1: + * dead 127.0.0.1 proxies, broken AAAA, and flaky HTTP/2 cause + * "Connection refused" / stream errors while browsers still work. * - * @param array $curlOptions - * @return array> + * @param array $curlOptions + * @return array */ - public static function httpClientOptions(array $curlOptions = []): array + public static function httpClientOptions(array $curlOptions = [], ?float $timeout = null): array { $curl = $curlOptions; @@ -92,7 +94,24 @@ class Controller extends BaseController } } - return $curl === [] ? [] : ['curl' => $curl]; + if (defined('CURLOPT_HTTP_VERSION') && defined('CURL_HTTP_VERSION_1_1')) { + $verOpt = (int) constant('CURLOPT_HTTP_VERSION'); + if (! array_key_exists($verOpt, $curl)) { + $curl[$verOpt] = (int) constant('CURL_HTTP_VERSION_1_1'); + } + } + + $options = ['proxy' => false]; + + if ($timeout !== null) { + $options['connect_timeout'] = min(5.0, max(2.0, (float) $timeout)); + } + + if ($curl !== []) { + $options['curl'] = $curl; + } + + return $options; } public static function Send($to , $msg, $is_customer = true) diff --git a/app/Http/Controllers/Site/PriceStreamController.php b/app/Http/Controllers/Site/PriceStreamController.php index 4fc177a..e2943d1 100644 --- a/app/Http/Controllers/Site/PriceStreamController.php +++ b/app/Http/Controllers/Site/PriceStreamController.php @@ -52,7 +52,12 @@ class PriceStreamController extends Controller } $cache = app(LivePriceCacheService::class); - $last = $cache->get() ?? $cache->buildFromDatabase(); + $config = app(\App\Services\ConfigService::class); + $last = $cache->get(); + // کش ممکن است no_quote صفرِ کوتاه‌عمر داشته باشد؛ با build از montzar_live_price جبران کن + if (! is_array($last) || ! $config->payloadHasDisplayablePrice($last)) { + $last = $cache->buildFromDatabase(); + } if (is_array($last) && $last !== []) { if ($customerId > 0) { $last = app(\App\Services\CustomerSpecialPricingService::class) diff --git a/app/Http/Controllers/Site/ReportController.php b/app/Http/Controllers/Site/ReportController.php index cf65a15..5136cdf 100644 --- a/app/Http/Controllers/Site/ReportController.php +++ b/app/Http/Controllers/Site/ReportController.php @@ -327,19 +327,19 @@ class ReportController extends Controller $config->UpdateConfig('montzar_closed', 'false'); } catch (\Throwable $e) { - $montzarLivePrice = 0; - \Log::error('MONZAR PRICE ERROR', [ 'message' => $e->getMessage(), 'montzar_product_id' => $montzarProductId ?? null, 'montzar_response' => $montzar ?? null, ]); + // قطع لحظه‌ای همکار → قیمت ذخیره‌شده را نگه دار و دوباره در تیک بعدی تلاش کن $config->applyMontzarSyncFailure(); - if (!$config->getBool('montzar_closed', true)) { - $stale = $config->getInt('montzar_live_price', 0); - $montzarLivePrice = $config->isPlausibleQuotePrice((float) $stale) ? $stale : 0; - } + $stale = $config->getInt('montzar_live_price', 0); + $montzarLivePrice = $config->isPlausibleQuotePrice((float) $stale) ? $stale : 0; + $montzarFlags = $config->montzarOpenFlags(); + $montzar_open_buy = $montzarFlags['open_buy']; + $montzar_open_sell = $montzarFlags['open_sell']; } } @@ -584,12 +584,9 @@ class ReportController extends Controller $config->UpdateConfig('montzar_closed', 'false'); } catch (\Throwable $e) { - $montzar = 0; $config->applyMontzarSyncFailure(); - if (!$config->getBool('montzar_closed', true)) { - $stale = $config->getInt('montzar_live_price', 0); - $montzar = $config->isPlausibleQuotePrice((float) $stale) ? $stale : 0; - } + $stale = $config->getInt('montzar_live_price', 0); + $montzar = $config->isPlausibleQuotePrice((float) $stale) ? $stale : 0; \Log::error('STORE MONZAR PRICE ERROR', ['message' => $e->getMessage()]); } } diff --git a/app/Services/ChatWebhookService.php b/app/Services/ChatWebhookService.php index f4c34ef..694ad58 100644 --- a/app/Services/ChatWebhookService.php +++ b/app/Services/ChatWebhookService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Http\Controllers\Controller; use App\Models\Setting; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -65,6 +66,7 @@ class ChatWebhookService for ($i = 1; $i <= $attempts; $i++) { try { $response = Http::timeout(8) + ->withOptions(Controller::httpClientOptions([], 8.0)) ->retry(0) ->post('https://'.$host.'/api/projects/set-webhook', [ 'api_key' => $chatApiKey, diff --git a/app/Services/LivePriceCacheService.php b/app/Services/LivePriceCacheService.php index fad52bf..0a3bda4 100644 --- a/app/Services/LivePriceCacheService.php +++ b/app/Services/LivePriceCacheService.php @@ -35,6 +35,20 @@ class LivePriceCacheService { $cached = Cache::get(self::FULL_RESPONSE_KEY); $cachedBody = is_array($cached['body'] ?? null) ? $cached['body'] : null; + $config = app(ConfigService::class); + + // no_quote صفر را روی مظنهٔ معتبر قبلی ننویس — قطع فید لحظه‌ای نباید UI را خالی کند + $incomingEmpty = ! $config->payloadHasDisplayablePrice($body) + && (string) ($body['status'] ?? '') === 'no_quote'; + $existingGood = is_array($cachedBody) && $config->payloadHasDisplayablePrice($cachedBody); + if ($incomingEmpty && $existingGood) { + Cache::put(self::FULL_RESPONSE_KEY, [ + 'body' => $cachedBody, + 'stored_at' => now(), + ], now()->addSeconds(self::TTL_SECONDS + 5)); + + return $cachedBody; + } if ($cachedBody !== null && PriceBroadcastService::lastPriceIdentity($cachedBody) === PriceBroadcastService::lastPriceIdentity($body)) { Cache::put(self::FULL_RESPONSE_KEY, [ @@ -95,7 +109,7 @@ class LivePriceCacheService return now()->diffInSeconds($lastTick) <= self::TICK_MAX_AGE_SECONDS; } - /** پاسخ سریع بدون API خارجی — فقط وقتی کران اخیراً tick زده (همان فرمول lastPrice) */ + /** پاسخ سریع بدون API خارجی — از montzar_live_price / آخرین ردیف prices */ public function buildFromDatabase(): array { $config = app(ConfigService::class); @@ -104,6 +118,28 @@ class LivePriceCacheService $base = $last ? (float) $last->price : 0.0; $provider = $config->priceProvider(); + $montzarSource = 0; + $zaarboxSource = 0; + $mainProductSource = 0; + $priceSource = 'database_last'; + + if ($provider === 'montzar') { + $liveMontzar = (float) $config->get('montzar_live_price', 0); + if ($liveMontzar > 0 && $config->isPlausibleQuotePrice($liveMontzar)) { + $base = $liveMontzar; + $montzarSource = (int) $liveMontzar; + $priceSource = 'montzar'; + } + } elseif ($provider === 'zaarbox' && $base > 0) { + $zaarboxSource = (int) $base; + $priceSource = 'zaarbox'; + } elseif ($provider === 'main_product' && $base > 0) { + $mainProductSource = (int) $base; + $priceSource = 'main_product'; + } + + // قبلاً فقط به جدول prices نگاه می‌کرد؛ اگر خالی بود SSE همان لحظه no_quote صفر + // می‌فرستاد و UI مظنهٔ معتبر HTTP را پاک می‌کرد — در حالی که montzar_live_price پر بود. if ($base <= 0) { $payload = [ 'sell' => 0, @@ -119,27 +155,6 @@ class LivePriceCacheService 'time' => now(), ]; } else { - $montzarSource = 0; - $zaarboxSource = 0; - $mainProductSource = 0; - $priceSource = 'database_last'; - - if ($provider === 'montzar') { - $liveMontzar = (float) $config->get('montzar_live_price', 0); - if ($liveMontzar > 0 && $config->isPlausibleQuotePrice($liveMontzar)) { - $base = $liveMontzar; - $montzarSource = (int) $liveMontzar; - $priceSource = 'montzar'; - } else { - $montzarSource = 0; - $priceSource = 'database_last'; - } - } elseif ($provider === 'zaarbox') { - $zaarboxSource = (int) $base; - } elseif ($provider === 'main_product') { - $mainProductSource = (int) $base; - } - $trade = $config->buildTradeDisplayPrices($base, $priceSource); $payload = [ diff --git a/app/Services/MontzarService.php b/app/Services/MontzarService.php index 2d3e3cd..2d563e1 100644 --- a/app/Services/MontzarService.php +++ b/app/Services/MontzarService.php @@ -31,11 +31,22 @@ class MontzarService return $this->baseUrl() . '/' . ltrim($path, '/'); } + /** Pending HTTP با همان گزینه‌های پایدار (بدون پروکسی / IPv4 / HTTP1.1) */ + private function http(float $timeout = 10) + { + $extraCurl = defined('CURLOPT_TCP_FASTOPEN') + ? [(int) constant('CURLOPT_TCP_FASTOPEN') => 1] + : []; + + return Http::timeout($timeout) + ->acceptJson() + ->withOptions(Controller::httpClientOptions($extraCurl, $timeout)); + } + public function montzarLogin(): string { try { - $response = Http::timeout(10) - ->acceptJson() + $response = $this->http(10) ->post($this->endpoint('/server/auth/login'), [ 'mobile' => $this->config()->Config('montzar_mobile'), 'password' => $this->config()->Config('montzar_password'), @@ -89,25 +100,57 @@ class MontzarService } public function productList(): array + { + $attempts = 3; + $lastError = null; + + for ($attempt = 1; $attempt <= $attempts; $attempt++) { + try { + return $this->productListOnce(); + } catch (\Throwable $e) { + $lastError = $e; + Log::warning('Montzar productList attempt failed', [ + 'attempt' => $attempt, + 'max' => $attempts, + 'base_url' => $this->baseUrl(), + 'error' => $e->getMessage(), + ]); + if ($attempt < $attempts) { + usleep(250_000 * $attempt); + } + } + } + + throw $lastError ?? new \RuntimeException('Montzar product list failed'); + } + + private function productListOnce(): array { $token = $this->getToken(); - $response = Http::timeout(7) - ->acceptJson() - ->withOptions(Controller::httpClientOptions( - defined('CURLOPT_TCP_FASTOPEN') ? [(int) constant('CURLOPT_TCP_FASTOPEN') => 1] : [] - )) - ->withToken($token) - ->get($this->endpoint('/server/customer/product/list')) - ->json(); + try { + $response = $this->http(7) + ->withToken($token) + ->get($this->endpoint('/server/customer/product/list')) + ->throw() + ->json(); + } catch (\Throwable $e) { + // توکن مرده / قطع شبکه — یک بار لاگین مجدد + $token = $this->montzarLogin(); + $response = $this->http(7) + ->withToken($token) + ->get($this->endpoint('/server/customer/product/list')) + ->throw() + ->json(); + } if ((isset($response['message']) && $response['message'] === 'no authorised') || $response === null) { $token = $this->montzarLogin(); - $response = Http::timeout(7) - ->acceptJson() + $response = $this->http(7) ->withToken($token) ->get($this->endpoint('/server/customer/product/list')) + ->throw() ->json(); } @@ -263,9 +306,7 @@ class MontzarService private function sendColleagueLedgerHttp(string $method, string $url, string $token, ?array $payload) { - $pending = Http::timeout(30) - ->acceptJson() - ->withToken($token); + $pending = $this->http(30)->withToken($token); if (strtoupper($method) === 'GET') { return $pending->get($url, $payload ?? []); @@ -368,8 +409,7 @@ class MontzarService private function postToMontzar(string $url, string $token, array $payload) { - return Http::timeout(15) - ->acceptJson() + return $this->http(15) ->withToken($token) ->post($url, $payload); } diff --git a/app/Services/PriceBroadcastService.php b/app/Services/PriceBroadcastService.php index 5820e09..524d0b8 100644 --- a/app/Services/PriceBroadcastService.php +++ b/app/Services/PriceBroadcastService.php @@ -98,6 +98,14 @@ class PriceBroadcastService $lastPayload = app(ConfigService::class)->applyHomePriceDisplayState($lastPayload); $lastPayload = app(ZaarboxPriceService::class)->attachGoldOunceToPayload($lastPayload); + // no_quote صفر را روی SSE پخش نکن — کلاینت مظنهٔ معتبر را پاک می‌کرد + // در حالی که /api/price/last هنوز قیمت داشت. + if (! app(ConfigService::class)->payloadHasDisplayablePrice($lastPayload) + && (string) ($lastPayload['status'] ?? '') === 'no_quote' + ) { + return; + } + if (! $this->rememberHashIfChanged(self::LAST_HASH_KEY, self::lastPriceIdentity($lastPayload))) { return; } diff --git a/app/Services/PriceChannelBroadcastService.php b/app/Services/PriceChannelBroadcastService.php index ae360dd..357093e 100644 --- a/app/Services/PriceChannelBroadcastService.php +++ b/app/Services/PriceChannelBroadcastService.php @@ -775,7 +775,7 @@ TXT; private function sendBale(string $token, string $chatId, string $message): \Illuminate\Http\Client\Response { - return Http::withOptions(Controller::httpClientOptions()) + return Http::withOptions(Controller::httpClientOptions([], 6.0)) ->withoutVerifying() ->timeout(6) ->post("https://tapi.bale.ai/bot{$token}/sendMessage", [ @@ -787,7 +787,7 @@ TXT; private function sendTelegram(string $token, string $chatId, string $message): \Illuminate\Http\Client\Response { // تلگرام مستقیم از سرور اغلب فیلتر است — از پروکسی wwwapp استفاده می‌شود (مثل کد قدیمی) - return Http::withOptions(Controller::httpClientOptions()) + return Http::withOptions(Controller::httpClientOptions([], 6.0)) ->timeout(6) ->get('https://api.wwwapp.ir/telegran_send.php', [ 'chatId' => $chatId, @@ -798,7 +798,7 @@ TXT; private function sendRubika(string $token, string $chatId, string $message): \Illuminate\Http\Client\Response { - return Http::withOptions(Controller::httpClientOptions()) + return Http::withOptions(Controller::httpClientOptions([], 6.0)) ->withoutVerifying() ->timeout(6) ->post("https://botapi.rubika.ir/v3/{$token}/sendMessage", [ diff --git a/app/Services/ProductPriceSyncService.php b/app/Services/ProductPriceSyncService.php index 113ee0b..7b9c016 100644 --- a/app/Services/ProductPriceSyncService.php +++ b/app/Services/ProductPriceSyncService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Http\Controllers\Controller; use App\Models\Product; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; @@ -179,7 +180,10 @@ class ProductPriceSyncService $CP = null; try { - $cooper = Http::timeout(8)->get('https://www.livedata.ir/main/static/p.json', [])->json(); + $cooper = Http::timeout(8) + ->withOptions(Controller::httpClientOptions([], 8.0)) + ->get('https://www.livedata.ir/main/static/p.json', []) + ->json(); if (isset($cooper['s_301006'], $cooper['s_200103'])) { $CP = (($cooper['s_301006'] / 1000000) * $cooper['s_200103']) * 10; } @@ -238,6 +242,7 @@ class ProductPriceSyncService try { $RPrices = Http::timeout(8) + ->withOptions(Controller::httpClientOptions([], 8.0)) ->get('http://212.23.201.173:8381/price_list', []) ->json(); } catch (\Throwable $e) { diff --git a/app/Services/SmsBilling/Providers/CentralSmsApiProvider.php b/app/Services/SmsBilling/Providers/CentralSmsApiProvider.php index 8d6d1d9..d5b602c 100644 --- a/app/Services/SmsBilling/Providers/CentralSmsApiProvider.php +++ b/app/Services/SmsBilling/Providers/CentralSmsApiProvider.php @@ -26,12 +26,8 @@ class CentralSmsApiProvider implements SmsProviderInterface ->withHeaders([ 'Content-Type' => 'application/json', 'secret_key' => 'f21debfb-83ac-4624-a2f4-f08375043181', - ]); - - $curlOptions = Controller::httpClientOptions(); - if ($curlOptions !== []) { - $http = $http->withOptions($curlOptions); - } + ]) + ->withOptions(Controller::httpClientOptions([], 15.0)); $host = trim((string) ($config['name_fa'] ?? '')); if ($host === '') { diff --git a/app/Services/SmsBilling/Providers/MeliPayamakProvider.php b/app/Services/SmsBilling/Providers/MeliPayamakProvider.php index 25ea9ad..8d4b413 100644 --- a/app/Services/SmsBilling/Providers/MeliPayamakProvider.php +++ b/app/Services/SmsBilling/Providers/MeliPayamakProvider.php @@ -29,7 +29,7 @@ class MeliPayamakProvider implements SmsProviderInterface ]; $handle = curl_init('https://rest.payamak-panel.com/api/SendSMS/SendSMS'); - curl_setopt_array($handle, [ + $opts = [ CURLOPT_HTTPHEADER => ['content-type: application/x-www-form-urlencoded'], CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYHOST => false, @@ -37,7 +37,16 @@ class MeliPayamakProvider implements SmsProviderInterface CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query($data), CURLOPT_TIMEOUT => 30, - ]); + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_PROXY => '', + ]; + if (defined('CURLOPT_IPRESOLVE') && defined('CURL_IPRESOLVE_V4')) { + $opts[CURLOPT_IPRESOLVE] = CURL_IPRESOLVE_V4; + } + if (defined('CURLOPT_HTTP_VERSION') && defined('CURL_HTTP_VERSION_1_1')) { + $opts[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1; + } + curl_setopt_array($handle, $opts); $response = curl_exec($handle); $error = curl_error($handle); diff --git a/app/Services/SmsBilling/SuperAdminSmsClient.php b/app/Services/SmsBilling/SuperAdminSmsClient.php index f2a1a63..ed3c792 100644 --- a/app/Services/SmsBilling/SuperAdminSmsClient.php +++ b/app/Services/SmsBilling/SuperAdminSmsClient.php @@ -2,6 +2,7 @@ namespace App\Services\SmsBilling; +use App\Http\Controllers\Controller; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -204,7 +205,8 @@ class SuperAdminSmsClient try { $pending = Http::withHeaders($this->authHeaders($method, $canonicalPath)) ->acceptJson() - ->timeout($timeout); + ->timeout($timeout) + ->withOptions(Controller::httpClientOptions([], (float) $timeout)); $url = $this->urlForBase($base, $relativeUrl); diff --git a/app/Services/WebsocketBroadcastService.php b/app/Services/WebsocketBroadcastService.php index ccd7b81..95110bc 100644 --- a/app/Services/WebsocketBroadcastService.php +++ b/app/Services/WebsocketBroadcastService.php @@ -84,7 +84,7 @@ class WebsocketBroadcastService try { $response = Http::timeout(3) ->withHeaders(['Content-Type' => 'application/json']) - ->withOptions(\App\Http\Controllers\Controller::httpClientOptions()) + ->withOptions(\App\Http\Controllers\Controller::httpClientOptions([], 3.0)) ->post($url, $body); if (! $response->successful()) { diff --git a/app/Services/ZaarboxPriceService.php b/app/Services/ZaarboxPriceService.php index e7ef07f..9d470bf 100644 --- a/app/Services/ZaarboxPriceService.php +++ b/app/Services/ZaarboxPriceService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Http\Controllers\Controller; use App\Models\Product; use App\Models\Setting; use Carbon\Carbon; @@ -120,6 +121,7 @@ class ZaarboxPriceService try { $prices = Http::withoutVerifying() ->timeout(12) + ->withOptions(Controller::httpClientOptions([], 12.0)) ->get($url) ->json(); @@ -543,7 +545,11 @@ class ZaarboxPriceService try { if ($apiUrl !== null && trim($apiUrl) !== '') { - $prices = Http::withoutVerifying()->timeout(12)->get($apiUrl)->json(); + $prices = Http::withoutVerifying() + ->timeout(12) + ->withOptions(Controller::httpClientOptions([], 12.0)) + ->get($apiUrl) + ->json(); } else { $prices = $this->fetchFeedWithFailover(); } @@ -804,7 +810,10 @@ class ZaarboxPriceService $responses = Http::pool(function ($pool) use ($now) { foreach (self::PERIODS as $key => $config) { $start = $now->copy()->subHours($config['hours']); - $pool->as($key)->withoutVerifying()->timeout(12)->withHeaders([ + $pool->as($key)->withoutVerifying() + ->timeout(12) + ->withOptions(Controller::httpClientOptions([], 12.0)) + ->withHeaders([ 'Referer' => 'https://tablo.montest.ir/', 'User-Agent' => 'Mozilla/5.0', ])->get($this->historyUrl($start, $now));