400 lines
10 KiB
JavaScript
400 lines
10 KiB
JavaScript
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,
|
|
}
|