const { buildRuntimeConfig } = require('../../config/server') const { request } = require('./wx-promisify') const { loadRouteState, saveRouteState } = require('./storage') const ROUTE_CHECK_INTERVAL_MS = 60 * 1000 const ROUTE_REQUEST_TIMEOUT_MS = 8000 const FAILURE_CHECK_INTERVAL_MS = 15 * 1000 let inFlightCheckPromise = null let lastFailureRouteCheckAt = 0 function normalizeServerKey(value) { const key = String(value || '').trim().toUpperCase() if (!key) { return '' } return key } function normalizeBaseUrl(value) { const text = String(value || '').trim() if (!text) { return '' } const withProtocol = /^https?:\/\//i.test(text) ? text : `https://${text}` return withProtocol.replace(/\/+$/, '') } function findServerKeyByBaseUrl(servers, baseUrl) { const target = normalizeBaseUrl(baseUrl) if (!target || !servers || typeof servers !== 'object') { return '' } const keys = Object.keys(servers) for (const key of keys) { const item = servers[key] const candidate = normalizeBaseUrl(item && item.baseUrl) if (candidate && candidate === target) { return key } } return '' } function normalizeRouteConfig(payload) { if (!payload || typeof payload !== 'object') { throw new Error('route-config.json 格式错误') } const rawServers = payload.servers && typeof payload.servers === 'object' ? payload.servers : {} const servers = {} Object.keys(rawServers).forEach((rawKey) => { const key = normalizeServerKey(rawKey) const baseUrl = normalizeBaseUrl(rawServers[rawKey] && rawServers[rawKey].baseUrl) if (!key || !baseUrl) { return } servers[key] = { baseUrl } }) const keys = Object.keys(servers) if (!keys.length) { throw new Error('route-config.json 缺少 servers.baseUrl') } let active = normalizeServerKey(payload.active) if (!active || !servers[active]) { active = keys[0] } const parsedCooldown = Number(payload.cooldownMinutes) const cooldownMinutes = Number.isFinite(parsedCooldown) ? Math.max(0, Math.round(parsedCooldown)) : 10 return { active, cooldownMinutes, servers, } } function canSwitchByCooldown(lastSwitchAt, cooldownMinutes, now) { if (!Number.isFinite(cooldownMinutes) || cooldownMinutes <= 0) { return true } const switchAt = Number(lastSwitchAt) || 0 if (!switchAt) { return true } return now - switchAt >= cooldownMinutes * 60 * 1000 } function getAppInstance(explicitApp) { if (explicitApp && explicitApp.globalData) { return explicitApp } try { return getApp() } catch (error) { return null } } function applyRuntimeConfig(app, baseUrl, activeServerKey, options = {}) { if (!app || !app.globalData) { return null } const runtimeConfig = buildRuntimeConfig({ baseUrl, activeServerKey, }) const previousBaseUrl = app.globalData.fontsBaseUrl || '' const switched = previousBaseUrl && previousBaseUrl !== runtimeConfig.fontsBaseUrl Object.assign(app.globalData, runtimeConfig) if (switched && options.resetDataCache !== false) { app.globalData.fonts = null app.globalData.defaultConfig = null } return runtimeConfig } async function fetchRouteConfig(baseUrl) { const runtimeConfig = buildRuntimeConfig({ baseUrl }) const response = await request({ url: runtimeConfig.routeConfigUrl, method: 'GET', timeout: ROUTE_REQUEST_TIMEOUT_MS, }) if (!response || response.statusCode < 200 || response.statusCode >= 300) { throw new Error(`读取 route-config 失败,状态码: ${response && response.statusCode}`) } return normalizeRouteConfig(response.data) } function resolveCurrentServerKey(routeConfig, state, currentBaseUrl) { if (!routeConfig || !routeConfig.servers) { return '' } const stateKey = normalizeServerKey(state && state.activeServerKey) if (stateKey && routeConfig.servers[stateKey]) { return stateKey } const fromBase = findServerKeyByBaseUrl(routeConfig.servers, currentBaseUrl) if (fromBase) { return fromBase } return normalizeServerKey(routeConfig.active) } async function runRouteCheck(explicitApp, options = {}) { const app = getAppInstance(explicitApp) if (!app || !app.globalData) { return { switched: false, reason: 'app_not_ready', } } const now = Date.now() const force = Boolean(options.force) const state = loadRouteState() const lastRouteCheckAt = Number(state.lastRouteCheckAt) || 0 if (!force && now - lastRouteCheckAt < ROUTE_CHECK_INTERVAL_MS) { return { switched: false, reason: 'interval_skip', } } const currentBaseUrl = normalizeBaseUrl(app.globalData.fontsBaseUrl) || buildRuntimeConfig().fontsBaseUrl let currentRouteConfig try { currentRouteConfig = await fetchRouteConfig(currentBaseUrl) } catch (error) { console.warn(`[route-manager] 读取当前路由配置失败(${options.reason || 'unknown'}):`, error) saveRouteState({ lastRouteCheckAt: now, }) return { switched: false, reason: 'fetch_current_failed', error, } } const currentServerKey = resolveCurrentServerKey(currentRouteConfig, state, currentBaseUrl) const desiredServerKey = normalizeServerKey(currentRouteConfig.active) const desiredServer = currentRouteConfig.servers[desiredServerKey] if (!currentServerKey || !desiredServerKey || !desiredServer) { saveRouteState({ routeConfigCache: currentRouteConfig, lastRouteCheckAt: now, }) return { switched: false, reason: 'invalid_server_key', } } if (desiredServerKey === currentServerKey) { const stableBaseUrl = currentRouteConfig.servers[currentServerKey].baseUrl applyRuntimeConfig(app, stableBaseUrl, currentServerKey, { resetDataCache: false }) saveRouteState({ activeServerKey: currentServerKey, routeConfigCache: currentRouteConfig, lastRouteCheckAt: now, }) return { switched: false, reason: 'already_active', activeServerKey: currentServerKey, } } let targetRouteConfig try { targetRouteConfig = await fetchRouteConfig(desiredServer.baseUrl) } catch (error) { console.warn(`[route-manager] 双确认读取目标配置失败(${currentServerKey}->${desiredServerKey}):`, error) saveRouteState({ activeServerKey: currentServerKey, routeConfigCache: currentRouteConfig, lastRouteCheckAt: now, }) return { switched: false, reason: 'fetch_target_failed', error, } } if (normalizeServerKey(targetRouteConfig.active) !== desiredServerKey) { console.warn( `[route-manager] 双确认未通过: 当前=${currentServerKey}, 期望=${desiredServerKey}, 目标返回=${targetRouteConfig.active}`, ) saveRouteState({ activeServerKey: currentServerKey, routeConfigCache: currentRouteConfig, lastRouteCheckAt: now, }) return { switched: false, reason: 'double_confirm_rejected', } } if (!canSwitchByCooldown(state.lastSwitchAt, currentRouteConfig.cooldownMinutes, now)) { console.info( `[route-manager] cooldown 未到,保留当前服务器 ${currentServerKey} (cooldownMinutes=${currentRouteConfig.cooldownMinutes})`, ) saveRouteState({ activeServerKey: currentServerKey, routeConfigCache: currentRouteConfig, lastRouteCheckAt: now, }) return { switched: false, reason: 'cooldown_blocked', } } applyRuntimeConfig(app, desiredServer.baseUrl, desiredServerKey) saveRouteState({ activeServerKey: desiredServerKey, routeConfigCache: targetRouteConfig, lastSwitchAt: now, lastRouteCheckAt: now, }) console.info(`[route-manager] 已切换路由 ${currentServerKey} -> ${desiredServerKey}`) return { switched: true, reason: 'switched', from: currentServerKey, to: desiredServerKey, } } function checkRoute(explicitApp, options = {}) { if (inFlightCheckPromise) { return inFlightCheckPromise } inFlightCheckPromise = runRouteCheck(explicitApp, options) .catch((error) => { console.warn('[route-manager] 路由检查异常:', error) return { switched: false, reason: 'unexpected_error', error, } }) .finally(() => { inFlightCheckPromise = null }) return inFlightCheckPromise } function hydrateRuntimeFromState(explicitApp) { const app = getAppInstance(explicitApp) if (!app || !app.globalData) { return null } const state = loadRouteState() const fallbackRuntime = buildRuntimeConfig() const cachedRouteConfig = state && state.routeConfigCache && typeof state.routeConfigCache === 'object' ? state.routeConfigCache : null const activeServerKey = normalizeServerKey(state && state.activeServerKey) let baseUrl = fallbackRuntime.fontsBaseUrl if (cachedRouteConfig && cachedRouteConfig.servers && activeServerKey && cachedRouteConfig.servers[activeServerKey]) { const cachedBaseUrl = normalizeBaseUrl(cachedRouteConfig.servers[activeServerKey].baseUrl) if (cachedBaseUrl) { baseUrl = cachedBaseUrl } } return applyRuntimeConfig(app, baseUrl, activeServerKey, { resetDataCache: false }) } function bootstrapRoute(explicitApp) { const app = getAppInstance(explicitApp) if (!app || !app.globalData) { return Promise.resolve(null) } hydrateRuntimeFromState(app) return checkRoute(app, { force: true, reason: 'launch', }) } function ensureRouteReady(explicitApp) { const app = getAppInstance(explicitApp) if (!app || !app.globalData) { return Promise.resolve() } const promise = app.globalData.routeReadyPromise if (promise && typeof promise.then === 'function') { return promise.catch(() => null) } return Promise.resolve() } function checkRouteOnShow(explicitApp) { return checkRoute(explicitApp, { force: false, reason: 'foreground', }) } function checkRouteOnFailure(explicitApp) { const now = Date.now() if (now - lastFailureRouteCheckAt < FAILURE_CHECK_INTERVAL_MS) { return Promise.resolve({ switched: false, reason: 'failure_interval_skip', }) } lastFailureRouteCheckAt = now return checkRoute(explicitApp, { force: true, reason: 'failure', }) } module.exports = { bootstrapRoute, ensureRouteReady, checkRouteOnShow, checkRouteOnFailure, checkRoute, }