update at 2026-02-10 14:10:20
This commit is contained in:
@@ -21,9 +21,10 @@ miniprogram/
|
||||
│ └── server.js # 远端地址/端口/API 路径统一配置
|
||||
├── utils/
|
||||
│ ├── core/ # 纯算法模块
|
||||
│ └── mp/ # 小程序 API 适配层
|
||||
│ └── mp/ # 小程序 API 适配层(含 route-manager)
|
||||
├── assets/fonts.json # 字体清单(由脚本生成)
|
||||
├── assets/default.json # 首次加载默认配置(内容/颜色/字号/默认字体)
|
||||
├── assets/route-config.json # 手动切换 A/B 服务器配置
|
||||
├── app.js / app.json / app.wxss
|
||||
└── project.config.json
|
||||
```
|
||||
@@ -47,9 +48,30 @@ miniprogram/
|
||||
- `apiPrefix`: API 前缀(默认 `/api`)
|
||||
- `fontsManifestPath`: 字体清单路径(默认 `/miniprogram/assets/fonts.json`)
|
||||
- `defaultConfigPath`: 默认配置路径(默认 `/miniprogram/assets/default.json`)
|
||||
- `routeConfigPath`: 路由配置路径(默认 `/miniprogram/assets/route-config.json`)
|
||||
|
||||
`app.js` 和 API 调用会自动使用该配置生成完整 URL。
|
||||
|
||||
## 手动切换 A/B 服务器(无需发版)
|
||||
|
||||
远端 `route-config.json`(A、B 都部署)示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"active": "A",
|
||||
"cooldownMinutes": 10,
|
||||
"servers": {
|
||||
"A": { "baseUrl": "https://fonts.biboer.cn" },
|
||||
"B": { "baseUrl": "https://mac.biboer.cn" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 冷启动时先读取当前服务器的 `route-config.json`。
|
||||
- 若发现 `active` 指向另一台服务器,会读取目标服务器配置做“双确认”。
|
||||
- 仅当目标服务器也返回相同 `active`,并且满足 `cooldownMinutes`,才切换。
|
||||
- 回前台会按 60 秒节流检查一次;API/配置请求失败时会触发一次兜底检查。
|
||||
|
||||
## 导出说明
|
||||
|
||||
- `SVG`:受微信限制,`shareFileMessage` 需由单次点击直接触发,建议逐个字体导出。
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { buildRuntimeConfig } = require('./config/server')
|
||||
const { bootstrapRoute, checkRouteOnShow } = require('./utils/mp/route-manager')
|
||||
|
||||
const runtimeConfig = buildRuntimeConfig()
|
||||
|
||||
@@ -8,5 +9,20 @@ App({
|
||||
apiTimeoutMs: 30000,
|
||||
fonts: null,
|
||||
defaultConfig: null,
|
||||
routeReadyPromise: null,
|
||||
},
|
||||
|
||||
onLaunch() {
|
||||
this.globalData.routeReadyPromise = bootstrapRoute(this)
|
||||
.catch((error) => {
|
||||
console.warn('[app] 路由初始化失败,使用当前配置继续运行:', error)
|
||||
return null
|
||||
})
|
||||
},
|
||||
|
||||
onShow() {
|
||||
checkRouteOnShow(this).catch((error) => {
|
||||
console.warn('[app] 回前台路由检查失败:', error)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
12
miniprogram/assets/route-config.json
Normal file
12
miniprogram/assets/route-config.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"active": "A",
|
||||
"cooldownMinutes": 10,
|
||||
"servers": {
|
||||
"A": {
|
||||
"baseUrl": "https://fonts.biboer.cn"
|
||||
},
|
||||
"B": {
|
||||
"baseUrl": "https://mac.biboer.cn"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const SERVER_CONFIG = {
|
||||
apiPrefix: '/api',
|
||||
fontsManifestPath: '/miniprogram/assets/fonts.json',
|
||||
defaultConfigPath: '/miniprogram/assets/default.json',
|
||||
routeConfigPath: '/miniprogram/assets/route-config.json',
|
||||
}
|
||||
|
||||
function buildOrigin() {
|
||||
@@ -33,16 +34,29 @@ function normalizePath(path, fallback) {
|
||||
return value.startsWith('/') ? value : `/${value}`
|
||||
}
|
||||
|
||||
function buildRuntimeConfig() {
|
||||
const origin = buildOrigin()
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
const value = String(baseUrl || '').trim()
|
||||
if (!value) {
|
||||
return buildOrigin()
|
||||
}
|
||||
|
||||
const withProtocol = /^https?:\/\//i.test(value) ? value : `https://${value}`
|
||||
return withProtocol.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function buildRuntimeConfig(options = {}) {
|
||||
const origin = normalizeBaseUrl(options.baseUrl)
|
||||
const apiPrefix = normalizePath(SERVER_CONFIG.apiPrefix, '/api').replace(/\/$/, '')
|
||||
const fontsManifestPath = normalizePath(SERVER_CONFIG.fontsManifestPath, '/miniprogram/assets/fonts.json')
|
||||
const defaultConfigPath = normalizePath(SERVER_CONFIG.defaultConfigPath, '/miniprogram/assets/default.json')
|
||||
const routeConfigPath = normalizePath(SERVER_CONFIG.routeConfigPath, '/miniprogram/assets/route-config.json')
|
||||
|
||||
return {
|
||||
activeServerKey: String(options.activeServerKey || '').trim(),
|
||||
fontsBaseUrl: origin,
|
||||
fontsManifestUrl: `${origin}${fontsManifestPath}`,
|
||||
defaultConfigUrl: `${origin}${defaultConfigPath}`,
|
||||
routeConfigUrl: `${origin}${routeConfigPath}`,
|
||||
svgRenderApiUrl: `${origin}${apiPrefix}/render-svg`,
|
||||
pngRenderApiUrl: `${origin}${apiPrefix}/render-png`,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { request, downloadFile, readFile } = require('./wx-promisify')
|
||||
const { ensureRouteReady, checkRouteOnFailure } = require('./route-manager')
|
||||
|
||||
const localFonts = require('../../assets/fonts')
|
||||
const localDefaultConfig = require('../../assets/default')
|
||||
@@ -111,6 +112,7 @@ function buildDefaultConfigUrl(manifestUrl, baseUrl) {
|
||||
|
||||
async function loadFontsManifest(options = {}) {
|
||||
const app = getApp()
|
||||
await ensureRouteReady(app)
|
||||
const manifestUrl = options.manifestUrl || app.globalData.fontsManifestUrl
|
||||
const baseUrl = options.baseUrl || app.globalData.fontsBaseUrl
|
||||
|
||||
@@ -138,7 +140,11 @@ async function loadFontsManifest(options = {}) {
|
||||
return fonts
|
||||
} catch (error) {
|
||||
console.warn('远程字体清单加载失败,回退到本地清单:', error)
|
||||
const fallbackFonts = normalizeManifest(localFonts, baseUrl)
|
||||
await checkRouteOnFailure(app).catch((routeError) => {
|
||||
console.warn('字体清单失败后的路由检查失败:', routeError)
|
||||
})
|
||||
const effectiveBaseUrl = app.globalData.fontsBaseUrl || baseUrl
|
||||
const fallbackFonts = normalizeManifest(localFonts, effectiveBaseUrl)
|
||||
app.globalData.fonts = fallbackFonts
|
||||
return fallbackFonts
|
||||
}
|
||||
@@ -146,6 +152,7 @@ async function loadFontsManifest(options = {}) {
|
||||
|
||||
async function loadDefaultConfig(options = {}) {
|
||||
const app = getApp()
|
||||
await ensureRouteReady(app)
|
||||
const manifestUrl = options.manifestUrl || app.globalData.fontsManifestUrl
|
||||
const baseUrl = options.baseUrl || app.globalData.fontsBaseUrl
|
||||
const defaultConfigUrl = options.defaultConfigUrl ||
|
||||
@@ -172,6 +179,9 @@ async function loadDefaultConfig(options = {}) {
|
||||
return remoteConfig
|
||||
} catch (error) {
|
||||
console.warn('远程 default.json 加载失败,回退到本地默认配置:', error)
|
||||
await checkRouteOnFailure(app).catch((routeError) => {
|
||||
console.warn('default 配置失败后的路由检查失败:', routeError)
|
||||
})
|
||||
const fallbackConfig = normalizeDefaultConfig(localDefaultConfig)
|
||||
app.globalData.defaultConfig = fallbackConfig
|
||||
return fallbackConfig
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { request } = require('./wx-promisify')
|
||||
const { ensureRouteReady, checkRouteOnFailure } = require('./route-manager')
|
||||
|
||||
function buildApiUrl() {
|
||||
const app = getApp()
|
||||
@@ -47,41 +48,50 @@ function decodeArrayBuffer(buffer) {
|
||||
|
||||
async function renderSvgByApi(payload) {
|
||||
const app = getApp()
|
||||
await ensureRouteReady(app)
|
||||
const timeout = app && app.globalData && app.globalData.apiTimeoutMs
|
||||
? Number(app.globalData.apiTimeoutMs)
|
||||
: 30000
|
||||
|
||||
const response = await request({
|
||||
url: buildApiUrl(),
|
||||
method: 'POST',
|
||||
timeout,
|
||||
header: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
fontId: payload.fontId,
|
||||
text: payload.text,
|
||||
fontSize: payload.fontSize,
|
||||
fillColor: payload.fillColor,
|
||||
letterSpacing: payload.letterSpacing,
|
||||
maxCharsPerLine: payload.maxCharsPerLine,
|
||||
},
|
||||
})
|
||||
try {
|
||||
const response = await request({
|
||||
url: buildApiUrl(),
|
||||
method: 'POST',
|
||||
timeout,
|
||||
header: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
fontId: payload.fontId,
|
||||
text: payload.text,
|
||||
fontSize: payload.fontSize,
|
||||
fillColor: payload.fillColor,
|
||||
letterSpacing: payload.letterSpacing,
|
||||
maxCharsPerLine: payload.maxCharsPerLine,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response || response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new Error(`渲染服务请求失败,状态码: ${response && response.statusCode}`)
|
||||
if (!response || response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new Error(`渲染服务请求失败,状态码: ${response && response.statusCode}`)
|
||||
}
|
||||
|
||||
const body = response.data || {}
|
||||
if (!body.ok) {
|
||||
throw new Error(body.error || '渲染服务返回错误')
|
||||
}
|
||||
|
||||
return normalizeResult(body.data)
|
||||
} catch (error) {
|
||||
await checkRouteOnFailure(app).catch((routeError) => {
|
||||
console.warn('SVG 渲染失败后的路由检查失败:', routeError)
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
const body = response.data || {}
|
||||
if (!body.ok) {
|
||||
throw new Error(body.error || '渲染服务返回错误')
|
||||
}
|
||||
|
||||
return normalizeResult(body.data)
|
||||
}
|
||||
|
||||
async function renderPngByApi(payload) {
|
||||
const app = getApp()
|
||||
await ensureRouteReady(app)
|
||||
const timeout = app && app.globalData && app.globalData.apiTimeoutMs
|
||||
? Number(app.globalData.apiTimeoutMs)
|
||||
: 30000
|
||||
@@ -93,39 +103,46 @@ async function renderPngByApi(payload) {
|
||||
: `${baseApiUrl.replace(/\/$/, '')}/render-png`
|
||||
)
|
||||
|
||||
const response = await request({
|
||||
url: apiUrl,
|
||||
method: 'POST',
|
||||
timeout,
|
||||
responseType: 'arraybuffer',
|
||||
header: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'image/png',
|
||||
},
|
||||
data: {
|
||||
fontId: payload.fontId,
|
||||
text: payload.text,
|
||||
fontSize: payload.fontSize,
|
||||
fillColor: payload.fillColor,
|
||||
letterSpacing: payload.letterSpacing,
|
||||
maxCharsPerLine: payload.maxCharsPerLine,
|
||||
},
|
||||
})
|
||||
try {
|
||||
const response = await request({
|
||||
url: apiUrl,
|
||||
method: 'POST',
|
||||
timeout,
|
||||
responseType: 'arraybuffer',
|
||||
header: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'image/png',
|
||||
},
|
||||
data: {
|
||||
fontId: payload.fontId,
|
||||
text: payload.text,
|
||||
fontSize: payload.fontSize,
|
||||
fillColor: payload.fillColor,
|
||||
letterSpacing: payload.letterSpacing,
|
||||
maxCharsPerLine: payload.maxCharsPerLine,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response || response.statusCode !== 200) {
|
||||
let message = `PNG 渲染服务请求失败,状态码: ${response && response.statusCode}`
|
||||
const maybeText = decodeArrayBuffer(response && response.data)
|
||||
if (maybeText && maybeText.includes('error')) {
|
||||
message = maybeText
|
||||
if (!response || response.statusCode !== 200) {
|
||||
let message = `PNG 渲染服务请求失败,状态码: ${response && response.statusCode}`
|
||||
const maybeText = decodeArrayBuffer(response && response.data)
|
||||
if (maybeText && maybeText.includes('error')) {
|
||||
message = maybeText
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
if (!(response.data instanceof ArrayBuffer)) {
|
||||
throw new Error('PNG 渲染服务返回格式无效')
|
||||
}
|
||||
if (!(response.data instanceof ArrayBuffer)) {
|
||||
throw new Error('PNG 渲染服务返回格式无效')
|
||||
}
|
||||
|
||||
return response.data
|
||||
return response.data
|
||||
} catch (error) {
|
||||
await checkRouteOnFailure(app).catch((routeError) => {
|
||||
console.warn('PNG 渲染失败后的路由检查失败:', routeError)
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
399
miniprogram/utils/mp/route-manager.js
Normal file
399
miniprogram/utils/mp/route-manager.js
Normal file
@@ -0,0 +1,399 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
const STORAGE_KEYS = {
|
||||
APP_STATE: 'font2svg:app-state',
|
||||
FAVORITES: 'font2svg:favorites',
|
||||
ROUTE_STATE: 'font2svg:route-state',
|
||||
}
|
||||
|
||||
function getStorage(key, fallbackValue) {
|
||||
@@ -49,6 +50,21 @@ function saveFavorites(favorites) {
|
||||
return unique
|
||||
}
|
||||
|
||||
function loadRouteState() {
|
||||
return getStorage(STORAGE_KEYS.ROUTE_STATE, {})
|
||||
}
|
||||
|
||||
function saveRouteState(partialState) {
|
||||
const current = loadRouteState()
|
||||
const next = {
|
||||
...current,
|
||||
...partialState,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
setStorage(STORAGE_KEYS.ROUTE_STATE, next)
|
||||
return next
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STORAGE_KEYS,
|
||||
getStorage,
|
||||
@@ -57,4 +73,6 @@ module.exports = {
|
||||
saveAppState,
|
||||
loadFavorites,
|
||||
saveFavorites,
|
||||
loadRouteState,
|
||||
saveRouteState,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user