diff --git a/src/utils/price.ts b/src/utils/price.ts index e14ce62..82c3afb 100644 --- a/src/utils/price.ts +++ b/src/utils/price.ts @@ -18,6 +18,38 @@ export const price = ref() export const lastPrice = ref() export const olderPrice = ref(0) +let lastPriceSourceAt = 0 +let consecutivePriceFetchFailures = 0 + +function hasValidPricePayload(data?: Price | null): boolean { + if (!data) { + return false + } + + return ( + getTradeBuyPrice(data) > 0 + || getTradeSellPrice(data) > 0 + || Number(data.price_last ?? data.price ?? 0) > 0 + ) +} + +export function markPriceSourceReceived() { + lastPriceSourceAt = Date.now() + consecutivePriceFetchFailures = 0 +} + +export function markPriceSourceFetchFailed() { + consecutivePriceFetchFailures += 1 +} + +export function hasRecentPriceFromSource(maxAgeMs = 45000): boolean { + return lastPriceSourceAt > 0 && Date.now() - lastPriceSourceAt < maxAgeMs +} + +export function consecutivePriceSourceFailures(): number { + return consecutivePriceFetchFailures +} + /** buy/sell نمایش و معامله — همان buy/sell/price_last از /api/price/last */ export function getTradeBuyPrice(p?: Price | null) { return p?.buy ?? p?.price_last ?? p?.up?.buy ?? 0 @@ -114,7 +146,7 @@ async function fetchUpPrices(): Promise { } } -export async function getLastPrices() { +export async function getLastPrices(): Promise { try { let dataLast = await getLastPrice() @@ -126,8 +158,11 @@ export async function getLastPrices() { } applyLastPricePayload(dataLast) + return hasValidPricePayload(dataLast) } catch (e) { console.log(e) + markPriceSourceFetchFailed() + return false } } @@ -142,6 +177,10 @@ export function applyLastPricePayload(dataLast: Price) { buy: getTradeBuyPrice(normalized), sell: getTradeSellPrice(normalized), } + + if (hasValidPricePayload(normalized)) { + markPriceSourceReceived() + } } export function percentPriceChanged() { diff --git a/src/utils/priceSocket.ts b/src/utils/priceSocket.ts index cf7d0e9..47dd13e 100644 --- a/src/utils/priceSocket.ts +++ b/src/utils/priceSocket.ts @@ -3,13 +3,29 @@ import type { Product } from '@/models/products' import { Modal } from 'ant-design-vue' import { getConfigApp } from '@/utils/config' import { findStoredAuthToken } from '@/utils/authToken' -import { priceSocketConnected, priceStreamLifecycle, priceWssEnabled } from '@/utils/priceSocketState' +import { + priceSocketConnected, + priceStreamConnectingSince, + priceStreamLifecycle, + priceWssEnabled, +} from '@/utils/priceSocketState' -export { priceSocketConnected, priceStreamLifecycle, priceWssEnabled, shouldUsePricePolling } from '@/utils/priceSocketState' +export { + priceSocketConnected, + priceStreamConnectingSince, + priceStreamLifecycle, + priceWssEnabled, + shouldUsePricePolling, +} from '@/utils/priceSocketState' const RECONNECT_BASE_MS = 3000 const RECONNECT_MAX_MS = 30000 const FAILURES_BEFORE_ALERT = 4 +const ALERT_COOLDOWN_MS = 5 * 60 * 1000 +const FALLBACK_POLL_INTERVAL_MS = 10000 +/** بدون قیمت معتبر از مرجع (SSE یا API) */ +const PRICE_SOURCE_STALE_MS_MOBILE = 50000 +const PRICE_SOURCE_STALE_MS_DESKTOP = 35000 /** بدون event قیمت؛ ping هر ۵ث از سرور activity را تازه می‌کند */ const STALE_STREAM_MS = 120000 const FALLBACK_POLL_DELAY_MS = 2500 @@ -23,8 +39,34 @@ let staleCheckTimer: ReturnType | undefined let reconnectAttempt = 0 let lastStreamEventAt = 0 let networkAlertOpen = false +let lastNetworkAlertAt = 0 let listenersBound = false let fallbackPollTimer: ReturnType | undefined +let fallbackPollInterval: ReturnType | undefined +let pendingPriceAlert = false + +function isMobileDevice(): boolean { + if (typeof navigator === 'undefined') { + return false + } + + return /iPhone|iPad|iPod|Android|Mobile/i.test(navigator.userAgent) +} + +function isPageVisible(): boolean { + return typeof document === 'undefined' || document.visibilityState === 'visible' +} + +function markConnectingLifecycle() { + priceStreamLifecycle.value = 'connecting' + if (priceStreamConnectingSince.value <= 0) { + priceStreamConnectingSince.value = Date.now() + } +} + +function clearConnectingLifecycle() { + priceStreamConnectingSince.value = 0 +} function isTruthySetting(value: unknown, defaultValue = true): boolean { if (value === null || value === undefined || value === '') { @@ -114,6 +156,30 @@ function clearFallbackPollTimer() { } } +function stopFallbackPolling() { + clearFallbackPollTimer() + if (fallbackPollInterval) { + clearInterval(fallbackPollInterval) + fallbackPollInterval = undefined + } +} + +function startFallbackPolling() { + if (fallbackPollInterval) { + return + } + + scheduleFallbackPoll() + fallbackPollInterval = setInterval(() => { + if (stopped || subscribers <= 0 || priceSocketConnected.value) { + stopFallbackPolling() + return + } + + void runFallbackPoll() + }, FALLBACK_POLL_INTERVAL_MS) +} + function scheduleFallbackPoll() { clearFallbackPollTimer() fallbackPollTimer = setTimeout(() => { @@ -130,21 +196,91 @@ async function runFallbackPoll() { } try { - const { getLastPrices } = await import('@/utils/price') + const { getLastPrices, hasRecentPriceFromSource, markPriceSourceFetchFailed } = await import( + '@/utils/price' + ) const { refreshProductsPageData } = await import( '@/views/base/products/composables/useProductsPageData' ) - await Promise.allSettled([getLastPrices(), refreshProductsPageData()]) + + const [priceResult] = await Promise.allSettled([ + getLastPrices(), + refreshProductsPageData(), + ]) + + const priceOk = + priceResult.status === 'fulfilled' + && priceResult.value === true + && hasRecentPriceFromSource(getPriceSourceStaleMs()) + + if (!priceOk) { + markPriceSourceFetchFailed() + } + + void maybeShowNetworkAlert() } catch (error) { console.warn('Price fallback poll failed', error) + const { markPriceSourceFetchFailed } = await import('@/utils/price') + markPriceSourceFetchFailed() + void maybeShowNetworkAlert() } } +function getPriceSourceStaleMs(): number { + return isMobileDevice() ? PRICE_SOURCE_STALE_MS_MOBILE : PRICE_SOURCE_STALE_MS_DESKTOP +} + +async function isRealPriceOutage(): Promise { + const { + hasRecentPriceFromSource, + consecutivePriceSourceFailures, + } = await import('@/utils/price') + + const staleMs = getPriceSourceStaleMs() + + if (hasRecentPriceFromSource(staleMs)) { + pendingPriceAlert = false + return false + } + + const fetchFailures = consecutivePriceSourceFailures() + + if (fetchFailures >= 2) { + return true + } + + if (!priceSocketConnected.value && fetchFailures >= 1 && reconnectAttempt >= 2) { + return true + } + + if (!isMobileDevice() && !navigator.onLine && fetchFailures >= 1) { + return true + } + + if (!isMobileDevice() && reconnectAttempt >= FAILURES_BEFORE_ALERT) { + return true + } + + return false +} + function showNetworkAlert() { if (networkAlertOpen || stopped || !priceWssEnabled.value) { return } + if (!isPageVisible()) { + pendingPriceAlert = true + return + } + + const now = Date.now() + if (now - lastNetworkAlertAt < ALERT_COOLDOWN_MS) { + return + } + + lastNetworkAlertAt = now + pendingPriceAlert = false networkAlertOpen = true Modal.confirm({ title: 'اتصال قیمت برقرار نیست', @@ -164,10 +300,12 @@ function showNetworkAlert() { }) } -function maybeShowNetworkAlert() { - if (reconnectAttempt >= FAILURES_BEFORE_ALERT || !navigator.onLine) { - showNetworkAlert() +async function maybeShowNetworkAlert() { + if (!(await isRealPriceOutage())) { + return } + + showNetworkAlert() } function scheduleReconnect() { @@ -278,18 +416,16 @@ function handleStreamLost(scheduleRetry = true) { priceStreamLifecycle.value = scheduleRetry ? 'connecting' : 'closed' if ((wasConnected || scheduleRetry) && scheduleRetry) { - scheduleFallbackPoll() + startFallbackPolling() } if (scheduleRetry && !stopped && subscribers > 0) { scheduleReconnect() - maybeShowNetworkAlert() } } function disconnectSourceOnly() { stopStaleCheck() - clearFallbackPollTimer() if (eventSource) { eventSource.onopen = null eventSource.onerror = null @@ -308,6 +444,7 @@ function connect() { if (!url) { priceSocketConnected.value = false priceStreamLifecycle.value = 'closed' + clearConnectingLifecycle() return } @@ -319,7 +456,7 @@ function connect() { disconnectSourceOnly() } - priceStreamLifecycle.value = 'connecting' + markConnectingLifecycle() try { eventSource = new EventSource(url) @@ -334,9 +471,10 @@ function connect() { eventSource.onopen = () => { reconnectAttempt = 0 clearReconnectTimer() - clearFallbackPollTimer() + stopFallbackPolling() networkAlertOpen = false priceStreamLifecycle.value = 'open' + clearConnectingLifecycle() priceSocketConnected.value = true touchStreamActivity() startStaleCheck() @@ -354,10 +492,11 @@ function connect() { function disconnect() { clearReconnectTimer() - clearFallbackPollTimer() + stopFallbackPolling() disconnectSourceOnly() priceSocketConnected.value = false priceStreamLifecycle.value = 'idle' + clearConnectingLifecycle() } export function forceReconnect() { @@ -382,7 +521,7 @@ function onBrowserOnline() { function onBrowserOffline() { handleStreamLost(true) - showNetworkAlert() + void maybeShowNetworkAlert() } function bindBrowserNetworkListeners() { @@ -415,6 +554,10 @@ export function startPriceSocket(settingGetter: (key: string) => unknown) { if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', onVisibilityChange) } + + if (typeof window !== 'undefined') { + window.addEventListener('pageshow', onPageShow) + } } export function stopPriceSocket() { @@ -430,19 +573,49 @@ export function stopPriceSocket() { if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', onVisibilityChange) } + + if (typeof window !== 'undefined') { + window.removeEventListener('pageshow', onPageShow) + } } function onVisibilityChange() { - if (document.visibilityState !== 'visible' || stopped || subscribers <= 0) { + if (stopped || subscribers <= 0) { return } - if (!eventSource || eventSource.readyState !== EventSource.OPEN) { + if (document.visibilityState === 'hidden') { reconnectAttempt = 0 + return + } + + reconnectAttempt = 0 + networkAlertOpen = false + + if (pendingPriceAlert) { + void maybeShowNetworkAlert() + } + + if (!eventSource || eventSource.readyState !== EventSource.OPEN) { forceReconnect() } } +function onPageShow(event: PageTransitionEvent) { + if (stopped || subscribers <= 0) { + return + } + + if (event.persisted || !eventSource || eventSource.readyState !== EventSource.OPEN) { + reconnectAttempt = 0 + networkAlertOpen = false + forceReconnect() + if (pendingPriceAlert) { + void maybeShowNetworkAlert() + } + } +} + export function isPriceWebSocketEnabled(settingGetter: (key: string) => unknown): boolean { getSetting = settingGetter if (!isTruthySetting(settingGetter('wss_enabled'), true)) { @@ -472,6 +645,10 @@ function shutdownPriceRealtimeChannel() { if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', onVisibilityChange) } + + if (typeof window !== 'undefined') { + window.removeEventListener('pageshow', onPageShow) + } } export function syncPriceRealtimeChannel(settingGetter: (key: string) => unknown) { diff --git a/src/utils/priceSocketState.ts b/src/utils/priceSocketState.ts index c6a02a7..d7a38eb 100644 --- a/src/utils/priceSocketState.ts +++ b/src/utils/priceSocketState.ts @@ -11,16 +11,27 @@ export type PriceStreamLifecycle = 'idle' | 'connecting' | 'open' | 'closed' /** چرخهٔ اتصال SSE — برای جلوگیری از polling همزمان با reconnect */ export const priceStreamLifecycle = ref('idle') -/** polling: realtime خاموش = همیشه | SSE در حال اتصال/وصل = نه | قطع واقعی = بله */ +/** زمان شروع reconnect — برای فعال‌کردن polling پشتیبان روی موبایل */ +export const priceStreamConnectingSince = ref(0) + +const CONNECTING_POLL_AFTER_MS = 8000 + +/** polling: realtime خاموش = همیشه | SSE وصل = نه | reconnect طولانی/قطع = بله */ export function shouldUsePricePolling(): boolean { if (!priceWssEnabled.value) { return true } - if ( - priceStreamLifecycle.value === 'connecting' - || priceStreamLifecycle.value === 'open' - ) { + if (priceStreamLifecycle.value === 'open') { + return false + } + + if (priceStreamLifecycle.value === 'connecting') { + const since = priceStreamConnectingSince.value + if (since > 0 && Date.now() - since >= CONNECTING_POLL_AFTER_MS) { + return true + } + return false }