Files
boursecu_server/app/Services/LivePriceCacheService.php
T
2026-08-26 21:47:09 +03:30

192 lines
6.6 KiB
PHP

<?php
namespace App\Services;
use App\Models\Price;
use Illuminate\Support\Facades\Cache;
class LivePriceCacheService
{
public const FULL_RESPONSE_KEY = 'live_price_full_response';
public const LAST_TICK_KEY = 'live_price_last_tick_at';
/** هم‌تراز با کران ۱۰ ثانیه‌ای */
public const TTL_SECONDS = 10;
/** اگر tick اخیر نباشد، buildFromDatabase قیمت را قفل می‌کند */
public const TICK_MAX_AGE_SECONDS = 20;
public function get(): ?array
{
$cached = Cache::get(self::FULL_RESPONSE_KEY);
if (! is_array($cached) || ! isset($cached['body'], $cached['stored_at'])) {
return null;
}
if (now()->diffInSeconds($cached['stored_at']) > self::TTL_SECONDS) {
return null;
}
return $cached['body'];
}
public function put(array $body): array
{
$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, [
'body' => $cachedBody,
'stored_at' => now(),
], now()->addSeconds(self::TTL_SECONDS + 5));
return $cachedBody;
}
$this->storeBody($body);
app(PriceBroadcastService::class)->broadcastLastIfChanged($body);
app(PriceChannelBroadcastService::class)->scheduleBroadcastIfEligible();
return $body;
}
/**
* بازنویسی کش last بدون گیت هویت — برای اعمال فوری ساعت کاری.
*
* @param array<string, mixed> $body
* @return array<string, mixed>
*/
public function replaceBody(array $body): array
{
$this->storeBody($body);
return $body;
}
/**
* @param array<string, mixed> $body
*/
private function storeBody(array $body): void
{
Cache::put(self::FULL_RESPONSE_KEY, [
'body' => $body,
'stored_at' => now(),
], now()->addSeconds(self::TTL_SECONDS + 5));
app(ProductDisplayPriceService::class)->cacheLivePricePayload($body);
}
public function markTick(): void
{
Cache::put(self::LAST_TICK_KEY, now(), now()->addMinutes(10));
}
public function hasRecentTick(): bool
{
$lastTick = Cache::get(self::LAST_TICK_KEY);
if ($lastTick === null) {
return false;
}
return now()->diffInSeconds($lastTick) <= self::TICK_MAX_AGE_SECONDS;
}
/** پاسخ سریع بدون API خارجی — از montzar_live_price / آخرین ردیف prices */
public function buildFromDatabase(): array
{
$config = app(ConfigService::class);
$display = app(ProductDisplayPriceService::class);
$last = Price::orderByDesc('id')->first();
$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,
'buy' => 0,
'price_last' => 0,
'price' => 0,
'main_product' => 0,
'montzar' => 0,
'zaarbox' => 0,
'closed' => false,
'status' => 'no_quote',
'price_source' => '',
'time' => now(),
];
} else {
$trade = $config->buildTradeDisplayPrices($base, $priceSource);
$payload = [
'sell' => $trade['sell'],
'buy' => $trade['buy'],
'price_last' => $trade['priceLast'],
'price' => (int) $base,
'main_product' => $mainProductSource,
'montzar' => $montzarSource,
'zaarbox' => $zaarboxSource,
'closed' => false,
'status' => 'open',
'price_source' => $priceSource,
'time' => now(),
];
}
$payload['last_historical'] = $display->resolveHistoricalBasePrice();
$payload = $config->applyGlobalTradingHoursToPayload($payload);
$extremes = $config->resolvedDailyBaseExtremes();
$payload['up'] = ($payload['status'] ?? 'open') !== 'open'
? ['sell' => 0, 'buy' => 0]
: ($extremes['up'] > 0
? [
'sell' => max(0, (int) ceil($extremes['down'] - $config->displaySellPriceDiffRial())),
'buy' => (int) ceil($extremes['up'] + $config->displayBuyPriceDiffRial()),
]
: ['sell' => $payload['sell'] ?? 0, 'buy' => $payload['buy'] ?? 0]);
return app(ZaarboxPriceService::class)->attachGoldOunceToPayload($payload);
}
}