From c3b868c6b53af6b6f8bd910d852f93fdd0ae658a Mon Sep 17 00:00:00 2001 From: adonis Date: Wed, 17 Jun 2026 12:12:35 +0330 Subject: [PATCH] chart fixed --- .../Admin/PriceSyncLogController.php | 8 +- .../Controllers/Admin/ProductController.php | 5 + app/Services/LivePriceTickService.php | 16 ++- app/Services/PriceBroadcastService.php | 81 ++++++++++++++++ app/Services/PriceSnapshotService.php | 35 ++++++- app/Services/PriceSyncLogService.php | 9 +- app/Services/WebsocketBroadcastService.php | 97 +++++++++++++++++++ app/Services/ZaarboxPriceService.php | 28 ++++-- database/sql/deploy_settings_const.sql | 36 ++++++- 9 files changed, 293 insertions(+), 22 deletions(-) create mode 100644 app/Services/PriceBroadcastService.php create mode 100644 app/Services/WebsocketBroadcastService.php diff --git a/app/Http/Controllers/Admin/PriceSyncLogController.php b/app/Http/Controllers/Admin/PriceSyncLogController.php index cbeda4f..9c981ae 100644 --- a/app/Http/Controllers/Admin/PriceSyncLogController.php +++ b/app/Http/Controllers/Admin/PriceSyncLogController.php @@ -31,12 +31,12 @@ class PriceSyncLogController extends Controller public function runSync(LivePriceTickService $tickService) { - $tickService->tick(); - - $log = PriceSyncLog::with('items')->orderByDesc('id')->first(); + $log = $tickService->tick(); return response()->json([ - 'message' => $log ? 'قیمت‌ها به‌روزرسانی شد' : 'همگام‌سازی انجام شد', + 'message' => $log + ? 'قیمت‌ها به‌روزرسانی شد' + : 'قیمت تغییری نکرد؛ رکورد جدیدی ثبت نشد', 'data' => $log, ]); } diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php index 6103ab4..fa84ead 100644 --- a/app/Http/Controllers/Admin/ProductController.php +++ b/app/Http/Controllers/Admin/ProductController.php @@ -18,6 +18,7 @@ use App\Services\ConfigService; use App\Services\ProductDisplayPriceService; use App\Services\ProductPriceService; use App\Services\ProductPriceSyncService; +use App\Services\PriceBroadcastService; use App\Services\CategoryService; use Illuminate\Support\Facades\Schema; @@ -251,6 +252,10 @@ class ProductController extends SuperController app(ProductPriceSyncService::class)->syncAll(true); } + dispatch(function () { + app(PriceBroadcastService::class)->broadcastAfterPriceUpdate(); + })->afterResponse(); + return response()->json(true); } diff --git a/app/Services/LivePriceTickService.php b/app/Services/LivePriceTickService.php index e8d74fa..8619d07 100644 --- a/app/Services/LivePriceTickService.php +++ b/app/Services/LivePriceTickService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Http\Controllers\Site\ReportController; +use App\Models\PriceSyncLog; use App\Models\Product; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; @@ -12,13 +13,15 @@ class LivePriceTickService { private const LOCK_SECONDS = 25; - public function tick(): void + public function tick(): ?PriceSyncLog { $lock = Cache::lock('prices:tick', self::LOCK_SECONDS); if (! $lock->get()) { - return; + return null; } + $syncLog = null; + try { $config = app(ConfigService::class); $sync = app(ProductPriceSyncService::class); @@ -73,15 +76,22 @@ class LivePriceTickService ->whereNotNull('reading_method') ->count(); - app(PriceSyncLogService::class)->recordTick($source, $sourceUrl, $liquidGold, $activeProducts); + $syncLog = app(PriceSyncLogService::class)->recordTick($source, $sourceUrl, $liquidGold, $activeProducts); if ($payload !== []) { app(PriceSnapshotService::class)->recordTick($payload); } app(LivePriceCacheService::class)->markTick(); + + $broadcastPayload = $payload; + dispatch(function () use ($broadcastPayload) { + app(PriceBroadcastService::class)->broadcastAfterPriceUpdate($broadcastPayload); + })->afterResponse(); } finally { $lock->release(); } + + return $syncLog; } } diff --git a/app/Services/PriceBroadcastService.php b/app/Services/PriceBroadcastService.php new file mode 100644 index 0000000..a53fcde --- /dev/null +++ b/app/Services/PriceBroadcastService.php @@ -0,0 +1,81 @@ +|null $lastPayload + */ + public function broadcastAfterPriceUpdate(?array $lastPayload = null): void + { + $ws = app(WebsocketBroadcastService::class); + if (! $ws->isEnabled()) { + return; + } + + if ($lastPayload === null) { + $cache = app(LivePriceCacheService::class); + $lastPayload = $cache->get() ?? $cache->buildFromDatabase(); + } + + if (is_array($lastPayload) && $lastPayload !== []) { + $this->broadcastLastIfChanged($lastPayload); + } + + $listPayload = $this->buildProductListPayload(); + if ($listPayload !== null) { + $this->broadcastListIfChanged($listPayload); + } + } + + /** + * @param array $payload + */ + public function broadcastLastIfChanged(array $payload): void + { + $hash = md5(json_encode($payload, JSON_UNESCAPED_UNICODE)); + if (Cache::get(self::LAST_HASH_KEY) === $hash) { + return; + } + + Cache::put(self::LAST_HASH_KEY, $hash, now()->addHour()); + app(WebsocketBroadcastService::class)->sendEvent('last', $payload); + } + + /** + * @param array $products + */ + public function broadcastListIfChanged(array $products): void + { + $hash = md5(json_encode($products, JSON_UNESCAPED_UNICODE)); + if (Cache::get(self::LIST_HASH_KEY) === $hash) { + return; + } + + Cache::put(self::LIST_HASH_KEY, $hash, now()->addHour()); + app(WebsocketBroadcastService::class)->sendEvent('list', ['data' => $products]); + } + + /** + * @return array|null + */ + private function buildProductListPayload(): ?array + { + $request = Request::create('/customer/product/list', 'GET'); + app()->instance('request', $request); + + $response = app(ProductController::class)->listProduct(); + $decoded = json_decode($response->getContent(), true); + + return is_array($decoded) ? $decoded : null; + } +} diff --git a/app/Services/PriceSnapshotService.php b/app/Services/PriceSnapshotService.php index 5748970..9208e71 100644 --- a/app/Services/PriceSnapshotService.php +++ b/app/Services/PriceSnapshotService.php @@ -8,9 +8,30 @@ use Carbon\Carbon; class PriceSnapshotService { - /** حداقل قیمت معتبر آبشده (ریال) — زیر این در نمودار نمایش داده نمی‌شود */ + /** حداقل قیمت معتبر آبشده (ریال) — برای تاریخچه ذخیره‌شده در DB */ public const MIN_VALID_CHART_PRICE_RIAL = 30_000_000; + /** حداقل پیش‌فرض نمودار Zaarbox — مس و کالاهای ارزان‌تر از آبشده */ + public const MIN_VALID_ZAARBOX_CHART_PRICE_RIAL = 1_000_000; + + /** + * آستانه حداقل قیمت نمودار تابلو — قابل تنظیم با chart_min_valid_price. + * پیش‌فرض Zaarbox پایین‌تر از آبشده است تا مس (~۲۲M) فیلتر نشود. + */ + public function minValidChartPriceRialForExternalFeed(): int + { + $configured = (int) app(ConfigService::class)->getString('chart_min_valid_price', '0'); + if ($configured > 0) { + return $configured; + } + + if (app(ConfigService::class)->usesZaarboxPricing()) { + return self::MIN_VALID_ZAARBOX_CHART_PRICE_RIAL; + } + + return self::MIN_VALID_CHART_PRICE_RIAL; + } + /** هر تیک کران — ذخیره در prices برای نمودار (هرگز صفر ثبت نمی‌شود) */ public function recordTick(array $payload): void { @@ -23,6 +44,11 @@ class PriceSnapshotService $priceValue = (int) $last->price; } + $lastRecorded = Price::query()->orderByDesc('id')->first(); + if ($lastRecorded && (int) $lastRecorded->price === $priceValue) { + return; + } + Price::create([ 'price' => $priceValue, 'date_at' => Carbon::now()->toDateString(), @@ -199,9 +225,10 @@ class PriceSnapshotService * @param array{DAILY?: array, WEEKLY?: array, MONTHLY?: array} $prices * @return array{DAILY: array, WEEKLY: array, MONTHLY: array} */ - public function sanitizeChartPrices(array $prices): array + public function sanitizeChartPrices(array $prices, ?int $minRial = null): array { - $minScaled = intdiv(self::MIN_VALID_CHART_PRICE_RIAL, 1000); + $minRial ??= self::MIN_VALID_CHART_PRICE_RIAL; + $minScaled = intdiv($minRial, 1000); foreach (['DAILY', 'WEEKLY', 'MONTHLY'] as $period) { if (! isset($prices[$period]) || ! is_array($prices[$period])) { @@ -220,7 +247,7 @@ class PriceSnapshotService continue; } - $isValid = $value > self::MIN_VALID_CHART_PRICE_RIAL + $isValid = $value > $minRial || ($value > $minScaled && $value <= self::MIN_VALID_CHART_PRICE_RIAL); if ($isValid) { diff --git a/app/Services/PriceSyncLogService.php b/app/Services/PriceSyncLogService.php index d5254f4..110f8de 100644 --- a/app/Services/PriceSyncLogService.php +++ b/app/Services/PriceSyncLogService.php @@ -57,10 +57,15 @@ class PriceSyncLogService return $log->load('items'); } - /** هر تیک کران — حتی بدون تغییر قیمت محصول */ + /** فقط وقتی قیمت مرجع آبشده نسبت به آخرین رکورد عوض شده باشد */ public function recordTick(string $source, ?string $sourceUrl, int $liquidGoldPrice, int $productsSynced): ?PriceSyncLog { - if (! $this->isAvailable()) { + if (! $this->isAvailable() || $liquidGoldPrice <= 0) { + return null; + } + + $last = PriceSyncLog::query()->orderByDesc('id')->first(); + if ($last && (int) $last->liquid_gold_price === $liquidGoldPrice) { return null; } diff --git a/app/Services/WebsocketBroadcastService.php b/app/Services/WebsocketBroadcastService.php new file mode 100644 index 0000000..4a6c9ce --- /dev/null +++ b/app/Services/WebsocketBroadcastService.php @@ -0,0 +1,97 @@ +getString('wss_link', '')); + if ($fromSetting !== '') { + return rtrim($fromSetting, '/'); + } + + return null; + } + + public function triggerPath(): string + { + $path = trim((string) app(ConfigService::class)->getString('wss_trigger_path', '')); + + return $path !== '' ? $path : self::DEFAULT_TRIGGER_PATH; + } + + public function isEnabled(): bool + { + if (! app(ConfigService::class)->getBool('wss_enabled', true)) { + return false; + } + + return $this->triggerBaseUrl() !== null; + } + + /** + * @param array $payload + */ + public function sendEvent(string $trigger, array $payload = []): void + { + if (! $this->isEnabled()) { + return; + } + + $baseUrl = $this->triggerBaseUrl(); + if ($baseUrl === null) { + return; + } + + $body = array_merge($payload, ['trigger' => $trigger]); + $host = $this->resolveHost(); + if ($host !== '') { + $body['host'] = $host; + } + + $url = $baseUrl.$this->triggerPath(); + + try { + Http::timeout(3) + ->withHeaders(['Content-Type' => 'application/json']) + ->withOptions([ + 'curl' => [ + CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, + ], + ]) + ->post($url, $body); + } catch (\Throwable $e) { + Log::warning('Websocket broadcast failed', [ + 'trigger' => $trigger, + 'url' => $url, + 'message' => $e->getMessage(), + ]); + } + } + + private function resolveHost(): string + { + $config = app(ConfigService::class); + $nameEn = trim((string) $config->getString('name_en', '')); + if ($nameEn !== '') { + return $nameEn; + } + + if (! app()->runningInConsole() && request()) { + return trim((string) request()->getHost()); + } + + return ''; + } +} diff --git a/app/Services/ZaarboxPriceService.php b/app/Services/ZaarboxPriceService.php index d264c14..00d539f 100644 --- a/app/Services/ZaarboxPriceService.php +++ b/app/Services/ZaarboxPriceService.php @@ -90,20 +90,23 @@ class ZaarboxPriceService */ public function getAllPrices(): array { + $snapshot = app(PriceSnapshotService::class); + if ($this->config->getBool('chart_use_local_data', false)) { - $local = app(PriceSnapshotService::class)->chartFromDatabase(); + $local = $snapshot->chartFromDatabase(); if (! empty($local['DAILY'])) { - return app(PriceSnapshotService::class)->sanitizeChartPrices($local); + return $snapshot->sanitizeChartPrices($local); } } + $minRial = $snapshot->minValidChartPriceRialForExternalFeed(); $cached = Cache::get($this->cacheKey()); if (is_array($cached) && isset($cached['prices'])) { - return app(PriceSnapshotService::class)->sanitizeChartPrices($cached['prices']); + return $snapshot->sanitizeChartPrices($cached['prices'], $minRial); } - return app(PriceSnapshotService::class)->sanitizeChartPrices($this->refreshCache()); + return $snapshot->sanitizeChartPrices($this->refreshCache(), $minRial); } /** @@ -483,6 +486,8 @@ class ZaarboxPriceService } }); + $minRial = app(PriceSnapshotService::class)->minValidChartPriceRialForExternalFeed(); + foreach (self::PERIODS as $key => $config) { $response = $responses[$key] ?? null; $start = $now->copy()->subHours($config['hours']); @@ -501,7 +506,8 @@ class ZaarboxPriceService $history, $this->toMillis($start), $this->toMillis($now), - $config['intervalMs'] + $config['intervalMs'], + $minRial ); } @@ -510,8 +516,14 @@ class ZaarboxPriceService private function historyUrl(Carbon $start, Carbon $end): string { + $base = rtrim($this->apiUrl(), '/'); + if (str_ends_with($base, '/free')) { + $base = substr($base, 0, -strlen('/free')); + } + return sprintf( - 'https://price.zaarbox.ir/api/history/%s?start=%s&end=%s&limit=1000000000', + '%s/history/%s?start=%s&end=%s&limit=1000000000', + $base, $this->historyId(), $this->toIso8601($start), $this->toIso8601($end) @@ -532,7 +544,7 @@ class ZaarboxPriceService * @param array> $history * @return array */ - private function mapHistory(array $history, int $startMs, int $endMs, int $intervalMs): array + private function mapHistory(array $history, int $startMs, int $endMs, int $intervalMs, int $minRial): array { $points = []; @@ -566,7 +578,7 @@ class ZaarboxPriceService for ($bucket = $firstBucket; $bucket <= $endMs; $bucket += $intervalMs) { while ($index < $count && $points[$index]['timestamp'] <= $bucket) { - if ($points[$index]['price'] > PriceSnapshotService::MIN_VALID_CHART_PRICE_RIAL) { + if ($points[$index]['price'] > $minRial) { $lastPoint = $points[$index]; } $index++; diff --git a/database/sql/deploy_settings_const.sql b/database/sql/deploy_settings_const.sql index 6507fd5..669525a 100644 --- a/database/sql/deploy_settings_const.sql +++ b/database/sql/deploy_settings_const.sql @@ -193,6 +193,23 @@ INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `de SELECT 'external_order_api_enabled', 'true', 'فعال‌سازی API سفارش خارجی (get_wallet / order)', 1, 1, 'true + node_client_id + node_shared_secret' WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'external_order_api_enabled' LIMIT 1); +-- WebSocket قیمت (برادکست last/list) +INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) +SELECT 'wss_enabled', 'true', 'قیمت لحظه‌ای از WebSocket', 1, 1, 'روشن = کلاینت از /prc/ws استفاده می‌کند؛ خاموش = polling', 'switch' +WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'wss_enabled' LIMIT 1); + +INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) +SELECT 'wss_path', '/prc/ws', 'مسیر WebSocket قیمت', 1, 1, 'مسیر پروکسی لایت‌اسپید — مثال: /prc/ws', 'text' +WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'wss_path' LIMIT 1); + +INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) +SELECT 'wss_link', '', 'آدرس trigger وب‌سوکت (سرور)', 1, 1, 'مثال: http://localhost:2090 — اگر خالی باشد از env WSS_LINK/wssLink', 'text' +WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'wss_link' LIMIT 1); + +INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) +SELECT 'wss_trigger_path', '/prc/api/trigger-update', 'مسیر trigger برادکست', 1, 1, 'POST برای ارسال last/list به سرویس ws', 'text' +WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'wss_trigger_path' LIMIT 1); + INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) SELECT 'montify_name', '', 'نام فروشگاه در درگاه', 1, 1, NULL, 'text' WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'montify_name' LIMIT 1); @@ -513,6 +530,18 @@ WHERE `key` = 'node_shared_secret'; UPDATE `settings` SET `title_fa` = COALESCE(NULLIF(TRIM(`title_fa`), ''), 'API سفارش خارجی') WHERE `key` = 'external_order_api_enabled'; +UPDATE `settings` SET `title_fa` = COALESCE(NULLIF(TRIM(`title_fa`), ''), 'قیمت لحظه‌ای از WebSocket') +WHERE `key` = 'wss_enabled'; + +UPDATE `settings` SET `title_fa` = COALESCE(NULLIF(TRIM(`title_fa`), ''), 'مسیر WebSocket قیمت') +WHERE `key` = 'wss_path'; + +UPDATE `settings` SET `title_fa` = COALESCE(NULLIF(TRIM(`title_fa`), ''), 'آدرس trigger وب‌سوکت') +WHERE `key` = 'wss_link'; + +UPDATE `settings` SET `title_fa` = COALESCE(NULLIF(TRIM(`title_fa`), ''), 'مسیر trigger برادکست') +WHERE `key` = 'wss_trigger_path'; + UPDATE `settings` SET `title_fa` = COALESCE(NULLIF(TRIM(`title_fa`), ''), 'بسته بودن همکار') WHERE `key` = 'montzar_closed'; @@ -580,7 +609,7 @@ UPDATE `settings` SET `type` = 'switch' WHERE `key` IN ( 'send_to_tahesab', 'profit_gold_api_enabled', 'payping_round_toman', 'payping_strict_failed_withdraw', 'has_page_earn_money', 'charge_notif', 'group_sms', 'montzar_closed', 'montzar_open_buy', 'montzar_open_sell', - 'payping_fin_sync_enabled', 'external_order_api_enabled', 'is_store', + 'payping_fin_sync_enabled', 'external_order_api_enabled', 'is_store', 'wss_enabled', 'register_referral_sms_to_referrer', 'register_referral_bonus_message', 'price_from_nasiryan' ); @@ -603,6 +632,7 @@ UPDATE `settings` SET `type` = 'text' WHERE `key` IN ( 'payping_start_url', 'payping_verify_url', 'payping_gateway_items', 'payping_fin_url', 'payping_fin_token', 'payping_gateway_item', 'tahesab_host', 'tahesab_token', 'tahesab_db', 'node_client_id', 'node_shared_secret', + 'wss_path', 'wss_link', 'wss_trigger_path', 'tahesab_bank_account', 'telegram_bot_token', 'bale_bot_token', 'support_messenger_id', 'support_messenger_text', 'referral_limit', 'referral_bonus_amount', 'balance_report_exclude_customer_id', 'metal_type', 'montify_name', 'price_extreme_date', 'default_vc_number', 'liman_token' @@ -929,6 +959,10 @@ UPDATE `settings` SET `description` = 'پاسخ JSON آخرین سفارش ار UPDATE `settings` SET `description` = 'شناسه کلاینت برای API سفارش خارجی (هدر X-Client-Id). باید با مقدار تنظیم‌شده در سرویس مقصد یکسان باشد.' WHERE `key` = 'node_client_id'; UPDATE `settings` SET `description` = 'کلید مشترک امضای HMAC برای احراز درخواست‌های API سفارش خارجی. محرمانه نگه دارید.' WHERE `key` = 'node_shared_secret'; UPDATE `settings` SET `description` = 'روشن: API دریافت کیف پول و ثبت سفارش از سرویس فروشگاه خارجی فعال است. نیاز به node_client_id و node_shared_secret دارد.' WHERE `key` = 'external_order_api_enabled'; +UPDATE `settings` SET `description` = 'روشن: اپ کاربر به‌جای polling مکرر /api/price/last و /customer/product/list از WebSocket استفاده می‌کند. نیاز به سرویس ws و پروکسی لایت‌اسپید دارد.' WHERE `key` = 'wss_enabled'; +UPDATE `settings` SET `description` = 'مسیر WebSocket روی دامنه اپ — معمولاً /prc/ws (پروکسی به پورت داخلی ws).' WHERE `key` = 'wss_path'; +UPDATE `settings` SET `description` = 'آدرس HTTP سرویس ws برای trigger برادکست از سرور لاراول — مثال: http://localhost:2090. اولویت با env WSS_LINK/wssLink است.' WHERE `key` = 'wss_link'; +UPDATE `settings` SET `description` = 'مسیر POST برای ارسال رویداد last/list به سرویس ws — پیش‌فرض: /prc/api/trigger-update' WHERE `key` = 'wss_trigger_path'; UPDATE `settings` SET `description` = 'نام فروشگاه که در درگاه پرداخت و رسید بانکی به کاربر نمایش داده می‌شود.' WHERE `key` = 'montify_name'; UPDATE `settings` SET `description` = 'روشن: پس از واریز/برداشت، تراکنش به سامانه حسابداری fin همگام‌سازی می‌شود.' WHERE `key` = 'payping_fin_sync_enabled';