topfans/frontend/utils/progress-manager.js
2026-04-07 23:08:49 +08:00

425 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 进度数据管理模块
* 处理进度数据的获取、缓存和轮询
*
* 数据降级策略:
* 1. 优先从API获取最新数据
* 2. API失败时使用缓存数据5分钟内有效
* 3. 缓存数据超过30秒标记为"可能过时"
* 4. 无缓存时返回默认数据
* 5. 通过UI指示器而非toast显示数据状态
*/
import { POLLING_CONFIG, CACHE_CONFIG } from './performance-config'
import performanceMonitor from './performance-monitor'
import { getActivityProgressApi } from './api.js'
// 开发配置
const DEV_CONFIG = {
USE_MOCK_DATA: false,
MOCK_INITIAL_PROGRESS: 100,
MOCK_INCREMENT_MIN: 50,
MOCK_INCREMENT_MAX: 100,
MOCK_TARGET: 1000,
MOCK_NETWORK_DELAY: 200
}
// 存储当前模拟进度值
let currentMockProgress = DEV_CONFIG.MOCK_INITIAL_PROGRESS
// 缓存配置
const CACHE_EXPIRY_TIME = CACHE_CONFIG.PROGRESS_DATA_EXPIRY
const POLLING_INTERVAL_MIN = POLLING_CONFIG.MIN_INTERVAL
const POLLING_INTERVAL_MAX = POLLING_CONFIG.MAX_INTERVAL
const MAX_RETRY_COUNT = 3
/**
* 计算下次轮询间隔,使用标准指数退避 + 均匀抖动
*
* 正常情况attempt=0[MIN, MAX] 之间随机
* 每次失败 attempt+1间隔翻倍上限 MAX_RETRY_INTERVAL60s
* 均匀抖动避免多客户端同时重试的惊群效应
*
* @param {number} attempt - 连续失败次数
* @returns {number} 轮询间隔(毫秒)
*/
function getRandomPollingInterval(attempt = 0) {
const backoff = POLLING_INTERVAL_MIN * Math.pow(2, attempt)
const capped = Math.min(backoff, POLLING_CONFIG.MAX_RETRY_INTERVAL)
// 均匀抖动:在 [capped * 0.75, capped * 1.25] 范围内随机,最小不低于 MIN_INTERVAL
const jitter = capped * 0.75 + Math.random() * capped * 0.5
return Math.max(Math.floor(jitter), POLLING_INTERVAL_MIN)
}
/**
* 生成客户端唯一标识uni-app 兼容版本)
* @returns {number} 客户端偏移量0-5000ms
*/
function getClientOffset() {
const globalData = getApp().globalData
if (!globalData.__clientOffset) {
const systemInfo = uni.getSystemInfoSync()
const factors = [
systemInfo.platform?.length || 0,
systemInfo.screenWidth || 0,
systemInfo.screenHeight || 0,
systemInfo.model?.length || 0,
systemInfo.system?.length || 0,
new Date().getTimezoneOffset()
]
const hash = factors.reduce((acc, val) => ((acc << 5) - acc) + val, 0)
globalData.__clientOffset = Math.abs(hash % 5000)
}
return globalData.__clientOffset
}
/**
* 进度数据管理器类
*/
export class ProgressManager {
constructor(activityId) {
this.activityId = activityId
this.pollingTimer = null
this._offsetTimer = null
this.isPolling = false
this.retryCount = 0
this.listeners = new Set()
this.isPageVisible = true
this.visibilityListener = null
}
addListener(listener) {
this.listeners.add(listener)
}
removeListener(listener) {
this.listeners.delete(listener)
}
notifyListeners(data) {
this.listeners.forEach(listener => {
try {
listener(data)
} catch (error) {
console.error('监听器执行错误:', error)
}
})
}
getCacheKey() {
return `progress_${this.activityId}`
}
getCachedData() {
try {
const cached = uni.getStorageSync(this.getCacheKey())
if (!cached || !cached.timestamp) return null
const cacheAge = Date.now() - cached.timestamp
if (cacheAge > CACHE_EXPIRY_TIME) {
uni.removeStorageSync(this.getCacheKey())
return null
}
return {
current: cached.current || 0,
target: cached.target || 1000,
isFromCache: true,
isStale: cacheAge > CACHE_CONFIG.STALE_DATA_THRESHOLD,
cacheAge,
lastUpdated: cached.timestamp
}
} catch (error) {
console.error('获取缓存数据失败:', error)
return null
}
}
cacheData(data) {
try {
uni.setStorageSync(this.getCacheKey(), {
current: data.current || 0,
target: data.target || 1000,
timestamp: Date.now()
})
} catch (error) {
console.error('缓存数据失败:', error)
}
}
async fetchFromAPI() {
const startTime = Date.now()
try {
if (DEV_CONFIG.USE_MOCK_DATA) {
await new Promise(resolve => setTimeout(resolve, DEV_CONFIG.MOCK_NETWORK_DELAY))
const increment = Math.floor(
Math.random() * (DEV_CONFIG.MOCK_INCREMENT_MAX - DEV_CONFIG.MOCK_INCREMENT_MIN + 1)
) + DEV_CONFIG.MOCK_INCREMENT_MIN
currentMockProgress = Math.min(currentMockProgress + increment, DEV_CONFIG.MOCK_TARGET)
const progressData = {
current: currentMockProgress,
target: DEV_CONFIG.MOCK_TARGET,
isFromCache: false,
isStale: false
}
this.cacheData(progressData)
this.retryCount = 0
performanceMonitor.recordPollingRequest(true, Date.now() - startTime)
return progressData
}
const response = await getActivityProgressApi(this.activityId)
if (response.code !== 200 || !response.data) {
throw new Error(response.message || 'API返回数据异常')
}
const progressData = {
current: response.data.current_progress || 0,
target: response.data.target_progress || 1000,
isFromCache: false,
isStale: false
}
this.cacheData(progressData)
this.retryCount = 0
performanceMonitor.recordPollingRequest(true, Date.now() - startTime)
return progressData
} catch (error) {
console.error('API获取进度数据失败:', error)
performanceMonitor.recordPollingRequest(false, Date.now() - startTime)
throw error
}
}
async getProgressData() {
try {
const data = await this.fetchFromAPI()
this.notifyListeners(data)
return data
} catch (error) {
console.warn('API获取失败尝试使用缓存数据:', error.message)
const cachedData = this.getCachedData()
if (cachedData) {
console.log('使用缓存数据,缓存年龄:', cachedData.cacheAge, 'ms')
this.notifyListeners(cachedData)
return cachedData
}
const defaultData = {
current: 0,
target: 1000,
isFromCache: false,
isStale: false,
isDefault: true
}
this.notifyListeners(defaultData)
return defaultData
}
}
/**
* 调度下一次轮询(统一入口,消除重复逻辑)
*/
_scheduleNextPoll() {
const interval = Math.max(getRandomPollingInterval(this.retryCount), POLLING_INTERVAL_MIN)
console.log(`[轮询] 下次轮询间隔: ${(interval / 1000).toFixed(1)}秒 (重试次数: ${this.retryCount})`)
this.pollingTimer = setTimeout(async () => {
if (!this.isPolling || !this.isPageVisible) {
console.log('[轮询] 状态变化,停止轮询')
return
}
try {
await this.getProgressData()
this.retryCount = 0
} catch (error) {
console.error('[轮询] 轮询失败:', error)
this.retryCount = Math.min(this.retryCount + 1, MAX_RETRY_COUNT)
}
if (this.isPolling && this.isPageVisible) {
this._scheduleNextPoll()
} else {
console.log('[轮询] 页面状态变化,停止调度下一次轮询')
}
}, interval)
}
startPolling() {
if (this.isPolling) {
console.log('[轮询] 轮询已在运行中,跳过启动')
return
}
this.isPolling = true
this.isPageVisible = true
const clientOffset = getClientOffset()
console.log(`[轮询] 准备启动,客户端偏移: ${(clientOffset / 1000).toFixed(1)}`)
this._offsetTimer = setTimeout(() => {
this._offsetTimer = null
if (!this.isPolling || !this.isPageVisible) {
console.log('[轮询] 页面已隐藏或轮询已停止,取消首次请求')
return
}
this.getProgressData().catch(err => {
console.error('[轮询] 首次轮询失败:', err)
this.retryCount++
})
this._scheduleNextPoll()
}, clientOffset)
console.log(`[轮询] 已启动,最小间隔: ${POLLING_INTERVAL_MIN / 1000}`)
}
stopPolling() {
if (this._offsetTimer) {
clearTimeout(this._offsetTimer)
this._offsetTimer = null
}
if (this.pollingTimer) {
clearTimeout(this.pollingTimer)
this.pollingTimer = null
}
this.isPolling = false
this.isPageVisible = false
console.log('[轮询] 进度数据轮询已停止')
}
pausePolling() {
console.log('[轮询] 页面隐藏,暂停轮询')
this.isPageVisible = false
if (this._offsetTimer) {
clearTimeout(this._offsetTimer)
this._offsetTimer = null
}
if (this.pollingTimer) {
clearTimeout(this.pollingTimer)
this.pollingTimer = null
console.log('[轮询] 已清除轮询定时器')
}
}
resumePolling() {
console.log('[轮询] 页面显示,恢复轮询')
this.isPageVisible = true
// 仅在轮询标志为真且没有任何定时器在运行时才恢复,防止竞态
if (this.isPolling && !this.pollingTimer && !this._offsetTimer) {
console.log('[轮询] 重新启动轮询调度')
this.getProgressData().catch(err => {
console.error('[轮询] 恢复后首次轮询失败:', err)
this.retryCount++
})
this._scheduleNextPoll()
}
}
async refresh() {
console.log('手动刷新进度数据')
// 暂停当前轮询循环,拉取最新数据后恢复,避免 stop+start 引入 clientOffset 延迟
const wasPolling = this.isPolling
if (wasPolling) {
this.pausePolling()
}
const data = await this.getProgressData()
if (wasPolling) {
this.isPolling = true
this.isPageVisible = true
this._scheduleNextPoll()
}
return data
}
destroy() {
console.log('[轮询] 销毁进度管理器,清理所有资源')
this.stopPolling()
this.listeners.clear()
console.log('[轮询] 进度管理器已销毁')
}
}
/**
* 创建进度管理器实例
* @param {string} activityId - 活动ID
* @returns {ProgressManager}
*/
export function createProgressManager(activityId) {
return new ProgressManager(activityId)
}
// { activityId -> { manager: ProgressManager, refCount: number } }
const managerCache = new Map()
/**
* 获取或创建进度管理器实例,内部维护引用计数。
* 每次调用 getProgressManager 必须对应一次 releaseProgressManager
* 引用计数归零时自动销毁实例并从缓存中移除。
* @param {string} activityId - 活动ID
* @returns {ProgressManager}
*/
export function getProgressManager(activityId) {
if (!managerCache.has(activityId)) {
managerCache.set(activityId, {
manager: new ProgressManager(activityId),
refCount: 0
})
}
const entry = managerCache.get(activityId)
entry.refCount++
return entry.manager
}
/**
* 释放对进度管理器的引用。
* 引用计数归零时自动销毁实例并清理缓存,防止内存泄漏。
* @param {string} activityId - 活动ID
*/
export function releaseProgressManager(activityId) {
const entry = managerCache.get(activityId)
if (!entry) return
entry.refCount--
if (entry.refCount <= 0) {
entry.manager.destroy()
managerCache.delete(activityId)
console.log(`[缓存] 进度管理器已释放: ${activityId}`)
}
}
/**
* 强制清理指定活动的进度管理器(忽略引用计数)
* @param {string} activityId - 活动ID
*/
export function cleanupProgressManager(activityId) {
const entry = managerCache.get(activityId)
if (entry) {
entry.manager.destroy()
managerCache.delete(activityId)
}
}
/**
* 清理所有进度管理器
*/
export function cleanupAllProgressManagers() {
managerCache.forEach(entry => entry.manager.destroy())
managerCache.clear()
}